From 7231d74665c83362a085e515ffca48d8ab45f623 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Wed, 8 Apr 2026 11:15:43 +0100 Subject: [PATCH 1/2] Update --- .../components/custom-branding-section.tsx | 36 + .../components/custom-branding-switch.tsx | 50 + .../components/space-branding-form.tsx | 56 + .../components/space-settings-form.tsx | 174 +- .../(space)/settings/general/page-client.tsx | 2 + apps/web/src/components/pro-badge.tsx | 2 +- apps/web/src/features/space/client.tsx | 7 +- packages/ui/package.json | 3 + packages/ui/src/button-variants.ts | 2 +- packages/ui/src/color-picker.tsx | 104 + packages/ui/src/field.tsx | 240 +++ packages/ui/src/sidebar.tsx | 2 +- pnpm-lock.yaml | 1815 ++++++++++++++++- 13 files changed, 2403 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx create mode 100644 apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx create mode 100644 apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx create mode 100644 packages/ui/src/color-picker.tsx create mode 100644 packages/ui/src/field.tsx diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx new file mode 100644 index 00000000000..ecd13ce1c6e --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Field, FieldDescription, FieldLabel } from "@rallly/ui/field"; +import { SparklesIcon } from "lucide-react"; +import { ProBadge } from "@/components/pro-badge"; +import { useSpace } from "@/features/space/client"; +import { Trans } from "@/i18n/client"; +import { CustomBrandingSwitch } from "./custom-branding-switch"; + +export function CustomBrandingSection() { + const { data: space } = useSpace(); + + return ( +
+ +
+ + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx new file mode 100644 index 00000000000..792d942e83b --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { toast } from "@rallly/ui/sonner"; +import { Switch } from "@rallly/ui/switch"; +import { useBilling } from "@/features/billing/client"; +import { useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; + +export function CustomBrandingSwitch({ + checked, + onChange, +}: { + checked: boolean; + onChange?: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + const { isFree, showPayWall } = useBilling(); + const updateShowBranding = trpc.spaces.updateShowBranding.useMutation(); + const utils = trpc.useUtils(); + + const handleToggle = (checked: boolean) => { + if (isFree) { + showPayWall(); + return; + } + toast.promise( + updateShowBranding + .mutateAsync({ showBranding: checked }) + .then(() => utils.spaces.getCurrent.invalidate()), + { + loading: t("savingBranding", { defaultValue: "Saving..." }), + success: checked + ? t("brandingEnabled", { defaultValue: "Custom branding enabled" }) + : t("brandingDisabled", { defaultValue: "Custom branding disabled" }), + error: t("brandingSaveError", { + defaultValue: "Failed to save branding", + }), + }, + ); + onChange?.(checked); + }; + + return ( + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx new file mode 100644 index 00000000000..e486f3634ea --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@rallly/ui/button"; +import { ColorPicker, parseColor } from "@rallly/ui/color-picker"; +import { toast } from "@rallly/ui/sonner"; +import React from "react"; +import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants"; +import type { SpaceDTO } from "@/features/space/types"; +import { Trans, useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; + +export function SpaceBrandingForm({ space }: { space: SpaceDTO }) { + const { t } = useTranslation(); + const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR; + const [color, setColor] = React.useState(() => parseColor(currentColor)); + const hexColor = color.toString("hex"); + const isDirty = hexColor !== currentColor; + + const updatePrimaryColor = trpc.spaces.updatePrimaryColor.useMutation(); + const utils = trpc.useUtils(); + + const handleSave = async () => { + const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor; + toast.promise( + updatePrimaryColor + .mutateAsync({ primaryColor: value }) + .then(() => utils.spaces.getCurrent.invalidate()), + { + loading: t("savingBranding", { defaultValue: "Saving..." }), + success: t("brandingSaved", { defaultValue: "Branding saved" }), + error: t("brandingSaveError", { + defaultValue: "Failed to save branding", + }), + }, + ); + }; + + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx index a1ae69ac9d1..2907860f7ab 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx @@ -2,23 +2,24 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@rallly/ui/button"; +import { ColorPicker } from "@rallly/ui/color-picker"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@rallly/ui/form"; + Field, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@rallly/ui/field"; import { Input } from "@rallly/ui/input"; import { toast } from "@rallly/ui/sonner"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { ImageUpload, ImageUploadControl, ImageUploadPreview, } from "@/components/image-upload"; +import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants"; import { SpaceIcon } from "@/features/space/components/space-icon"; import type { SpaceDTO } from "@/features/space/types"; import { Trans, useTranslation } from "@/i18n/client"; @@ -29,6 +30,8 @@ const spaceSettingsSchema = z.object({ .string() .min(1, "Space name is required") .max(100, "Space name must be less than 100 characters"), + primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color"), + showBranding: z.boolean(), }); interface SpaceSettingsFormProps { @@ -51,6 +54,8 @@ export function SpaceSettingsForm({ resolver: zodResolver(spaceSettingsSchema), defaultValues: { name: space.name, + primaryColor: space.primaryColor ?? DEFAULT_PRIMARY_COLOR, + showBranding: space.showBranding, }, }); @@ -63,79 +68,98 @@ export function SpaceSettingsForm({ }; return ( -
- { - toast.promise( - updateSpace.mutateAsync({ - name: data.name, + { + toast.promise( + updateSpace.mutateAsync({ + name: data.name, + }), + { + loading: t("updatingSpace", { + defaultValue: "Updating space...", }), - { - loading: t("updatingSpace", { - defaultValue: "Updating space...", - }), - success: t("spaceUpdatedSuccess", { - defaultValue: "Space updated successfully", - }), - error: t("spaceUpdatedError", { - defaultValue: "Failed to update space", - }), - }, - ); + success: t("spaceUpdatedSuccess", { + defaultValue: "Space updated successfully", + }), + error: t("spaceUpdatedError", { + defaultValue: "Failed to update space", + }), + }, + ); - form.reset(data); - })} - className="space-y-6" - > -
- - - - - - - - getImageUploadUrl.mutateAsync(input)} - onUploadSuccess={handleImageUploadSuccess} - onRemoveSuccess={handleImageRemoveSuccess} - hasCurrentImage={!!space.image} - /> - -
- ( - - - - - + form.reset(data); + })} + className="space-y-6" + > +
+ + + + + + + + + + getImageUploadUrl.mutateAsync(input)} + onUploadSuccess={handleImageUploadSuccess} + onRemoveSuccess={handleImageRemoveSuccess} + hasCurrentImage={!!space.image} + /> + + + ( + + + + - - - - )} - /> -
- -
- - + {fieldState.invalid && ( + + )} +
+ )} + /> + ( + + + + + + {fieldState.invalid && ( + + )} + + )} + /> +
+
+ + + + ); } diff --git a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx index f0e4aeb6280..13adb044c00 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx @@ -21,6 +21,7 @@ import { import { useSpace } from "@/features/space/client"; import { Trans } from "@/i18n/client"; import { trpc } from "@/trpc/client"; +import { CustomBrandingSection } from "./components/custom-branding-section"; import { DeleteSpaceButton } from "./components/delete-space-button"; import { LeaveSpaceButton } from "./components/leave-space-button"; import { SpaceSettingsForm } from "./components/space-settings-form"; @@ -64,6 +65,7 @@ export function GeneralSettingsPageClient() { + {!isOwner ? ( diff --git a/apps/web/src/components/pro-badge.tsx b/apps/web/src/components/pro-badge.tsx index 46502d13be6..e091de1c954 100644 --- a/apps/web/src/components/pro-badge.tsx +++ b/apps/web/src/components/pro-badge.tsx @@ -4,7 +4,7 @@ import { PLAN_NAMES } from "@/features/billing/constants"; export const ProBadge = ({ className }: { className?: string }) => { return ( - + {PLAN_NAMES.PRO} ); diff --git a/apps/web/src/features/space/client.tsx b/apps/web/src/features/space/client.tsx index a3ea3560f25..19dfd857921 100644 --- a/apps/web/src/features/space/client.tsx +++ b/apps/web/src/features/space/client.tsx @@ -59,9 +59,10 @@ export function SpaceProvider({ children }: { children: React.ReactNode }) { return ; } - const primaryColorVars = space.primaryColor - ? getPrimaryColorVars(space.primaryColor) - : null; + const primaryColorVars = + space.showBranding && space.primaryColor + ? getPrimaryColorVars(space.primaryColor) + : null; return ( <> diff --git a/packages/ui/package.json b/packages/ui/package.json index 6105015d3ff..2cc41882a56 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,6 +44,9 @@ "lucide-react": "^0.479.0", "next-themes": "^0.4.6", "react": "19.2.4", + "@react-aria/color": "^3.1.5", + "@react-stately/color": "^3.9.5", + "react-aria-components": "^1.16.0", "react-dom": "19.2.4", "react-hook-form": "^7.68.0", "sonner": "^2.0.6", diff --git a/packages/ui/src/button-variants.ts b/packages/ui/src/button-variants.ts index 54afc75f622..25c525fead3 100644 --- a/packages/ui/src/button-variants.ts +++ b/packages/ui/src/button-variants.ts @@ -4,7 +4,7 @@ import { cn } from "./lib/utils"; export const buttonVariants = cva( cn( - "group inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-normal outline-none transition-opacity transition-transform focus-visible:ring-2 focus-visible:ring-ring not-[[aria-haspopup=menu]]:active:translate-y-0.5 active:shadow-none disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:opacity-90", + "group inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-normal outline-none transition-opacity transition-transform focus-visible:ring-2 focus-visible:ring-ring active:shadow-none disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:opacity-90", ), { variants: { diff --git a/packages/ui/src/color-picker.tsx b/packages/ui/src/color-picker.tsx new file mode 100644 index 00000000000..ee6a01ba687 --- /dev/null +++ b/packages/ui/src/color-picker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useColorField } from "@react-aria/color"; +import { useColorFieldState } from "@react-stately/color"; +import React from "react"; +import type { + Color, + ColorPickerProps as RAColorPickerProps, +} from "react-aria-components"; +import { + ColorArea, + ColorPicker as ColorPickerPrimitive, + ColorPickerStateContext, + ColorSlider, + ColorSwatch, + ColorThumb, + SliderTrack, +} from "react-aria-components"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "./input-group"; +import { cn } from "./lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; + +export type ColorPickerProps = Pick< + RAColorPickerProps, + "value" | "onChange" | "defaultValue" +>; + +export type { Color }; +export { parseColor } from "react-aria-components"; + +function HexColorInput() { + const pickerState = React.useContext(ColorPickerStateContext); + const inputRef = React.useRef(null); + + const state = useColorFieldState({ + value: pickerState?.color ?? undefined, + onChange: (color: Color | null) => { + if (color) pickerState?.setColor(color); + }, + }); + + const { inputProps } = useColorField( + { "aria-label": "Hex color" }, + state, + inputRef, + ); + + return ( + + ); +} + +export function ColorPicker(props: ColorPickerProps) { + return ( + + + + + }> + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/field.tsx b/packages/ui/src/field.tsx new file mode 100644 index 00000000000..29dd7ef2f03 --- /dev/null +++ b/packages/ui/src/field.tsx @@ -0,0 +1,240 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "./label"; +import { cn } from "./lib/utils"; +import { Separator } from "./separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "@md/field-group:flex-row flex-col @md/field-group:items-center *:w-full @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( + // biome-ignore lint/a11y/useSemanticElements: avoid messing with shadcn defualt implementation +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +