diff --git a/packages/react-router-v7-example/.dockerignore b/packages/react-router-v7-example/.dockerignore new file mode 100644 index 000000000..9b8d51471 --- /dev/null +++ b/packages/react-router-v7-example/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/packages/react-router-v7-example/.gitignore b/packages/react-router-v7-example/.gitignore new file mode 100644 index 000000000..9b7c041f9 --- /dev/null +++ b/packages/react-router-v7-example/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/packages/react-router-v7-example/Dockerfile b/packages/react-router-v7-example/Dockerfile new file mode 100644 index 000000000..207bf937e --- /dev/null +++ b/packages/react-router-v7-example/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/packages/react-router-v7-example/README.md b/packages/react-router-v7-example/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react-router-v7-example/app/app.css b/packages/react-router-v7-example/app/app.css new file mode 100644 index 000000000..ea0b872ee --- /dev/null +++ b/packages/react-router-v7-example/app/app.css @@ -0,0 +1,138 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; +} + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + table { + @apply border-separate; + } +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/index.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/index.tsx new file mode 100644 index 000000000..5c5f0340b --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/index.tsx @@ -0,0 +1,75 @@ +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '~/components/ui/sheet'; +import { + setSidebarTab, + toggleInspectorSidebarOpen, + useInspectorSidebarOpen, + useSelectedSidebarTab, +} from '~/context/editor'; +import { useIsMobile } from '~/hooks/use-mobile'; +import { InspectPanel } from './inspectPanel'; +import { StylesPanel } from './stylesPanel'; + +const tabs = [ + { id: 'styles', label: 'Styles' }, + { id: 'block-configuration', label: 'Inspect' }, +] as const; + +export function InspectorSidebar() { + const sidebarTab = useSelectedSidebarTab(); + const isMobile = useIsMobile(); + const isOpen = useInspectorSidebarOpen(); + + const renderCurrentSidebarPanel = () => { + switch (sidebarTab) { + case 'block-configuration': + return ; + case 'styles': + return ; + } + }; + + const sidebarContent = ( + <> +
+
+ {tabs.map((tab) => ( + + ))} +
+
+
{renderCurrentSidebarPanel()}
+ + ); + + if (isMobile) { + return ( + + + + + {sidebarContent} + + + ); + } + + return ( +
+ {sidebarContent} +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/index.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/index.tsx new file mode 100644 index 000000000..514472cbc --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/index.tsx @@ -0,0 +1,68 @@ +import { setDocument, useDocument, useSelectedBlockId } from '~/context/editor'; +import type { TEditorBlock } from '~/documents/editor/core'; +import AvatarSidebarPanel from './panels/AvatarSidebarPanel'; +import ButtonSidebarPanel from './panels/ButtonSidebarPanel'; +import ColumnsContainerSidebarPanel from './panels/ColumnsContainerSidebarPanel'; +import ContainerSidebarPanel from './panels/ContainerSidebarPanel'; +import DividerSidebarPanel from './panels/DividerSidebarPanel'; +import HeadingSidebarPanel from './panels/HeadingSidebarPanel'; +import HtmlSidebarPanel from './panels/HtmlSidebarPanel'; +import ImageSidebarPanel from './panels/ImageSidebarPanel'; +import SpacerSidebarPanel from './panels/SpacerSidebarPanel'; +import TextSidebarPanel from './panels/TextSidebarPanel'; + +export function InspectPanel() { + const document = useDocument(); + const selectedBlockId = useSelectedBlockId(); + + if (!selectedBlockId) { + return

Click on a block to inspect.

; + } + const block = document[selectedBlockId]; + if (!block) { + return ( +

+ Block with id ${selectedBlockId} was not found. Click on a block to reset. +

+ ); + } + + const { data, type } = block; + + // TypePanel will pass out new data, and setBlock will update the selected block + const setBlock = (conf: TEditorBlock) => setDocument({ [selectedBlockId]: conf }); + + switch (type) { + case 'Avatar': + return setBlock({ type, data })} />; + case 'Button': + return setBlock({ type, data })} />; + case 'ColumnsContainer': + return ( + setBlock({ type, data })} /> + ); + case 'Container': + return setBlock({ type, data })} />; + case 'Divider': + return setBlock({ type, data })} />; + case 'Heading': + return setBlock({ type, data })} />; + case 'Html': + return setBlock({ type, data })} />; + case 'Image': + return setBlock({ type, data })} />; + case 'Spacer': + return setBlock({ type, data })} />; + case 'Text': + return setBlock({ type, data })} />; + default: + return ( +
+

+ block type {type} is not supported yet. +

+
{JSON.stringify(block, null, '  ')}
; +
+ ); + } +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/AvatarSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/AvatarSidebarPanel.tsx new file mode 100644 index 000000000..38912cf4e --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/AvatarSidebarPanel.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; + +import { type AvatarProps, AvatarPropsDefaults, AvatarPropsSchema } from '@usewaypoint/block-avatar'; +import { PanelWrapper } from './helpers/panelWrapper'; +import { ToggleGroupInput } from './helpers/toggleGroupInput'; +import { SliderInput } from './helpers/sliderInput'; +import { StyleInput } from './helpers/styleInput'; +import { TextInput } from './helpers/textInput'; + +type AvatarSidebarPanelProps = { + data: AvatarProps; + setData: (v: AvatarProps) => void; +}; +export default function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = AvatarPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const size = data.props?.size ?? AvatarPropsDefaults.size; + const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl; + const alt = data.props?.alt ?? AvatarPropsDefaults.alt; + const shape = data.props?.shape ?? AvatarPropsDefaults.shape; + + return ( + + { + updateData({ ...data, props: { ...data.props, size } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, shape } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, imageUrl: e.target.value } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, alt: e.target.value } }); + }} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ButtonSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ButtonSidebarPanel.tsx new file mode 100644 index 000000000..e12fcb784 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ButtonSidebarPanel.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import { type ButtonProps, ButtonPropsDefaults, ButtonPropsSchema } from '@usewaypoint/block-button'; + +import { PanelWrapper } from './helpers/panelWrapper'; +import { TextInput } from './helpers/textInput'; +import { ToggleGroupInput } from './helpers/toggleGroupInput'; +import { ColorInput } from './helpers/colorInput'; +import { StyleInput } from './helpers/styleInput'; + +type ButtonSidebarPanelProps = { + data: ButtonProps; + setData: (v: ButtonProps) => void; +}; +export default function ButtonSidebarPanel({ data, setData }: ButtonSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = ButtonPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const text = data.props?.text ?? ButtonPropsDefaults.text; + const url = data.props?.url ?? ButtonPropsDefaults.url; + const fullWidth = data.props?.fullWidth ?? ButtonPropsDefaults.fullWidth; + const size = data.props?.size ?? ButtonPropsDefaults.size; + const buttonStyle = data.props?.buttonStyle ?? ButtonPropsDefaults.buttonStyle; + const buttonTextColor = data.props?.buttonTextColor ?? ButtonPropsDefaults.buttonTextColor; + const buttonBackgroundColor = data.props?.buttonBackgroundColor ?? ButtonPropsDefaults.buttonBackgroundColor; + + return ( + + { + updateData({ ...data, props: { ...data.props, text: e.target.value } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, url: e.target.value } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, fullWidth: v === 'FULL_WIDTH' } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, size } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, buttonStyle } }); + }} + /> + + updateData({ ...data, props: { ...data.props, buttonTextColor } })} + /> + + updateData({ ...data, props: { ...data.props, buttonBackgroundColor } })} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ColumnsContainerSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ColumnsContainerSidebarPanel.tsx new file mode 100644 index 000000000..b79b77012 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ColumnsContainerSidebarPanel.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; + +import { AlignBottomIcon, AlignCenterVerticallyIcon, AlignTopIcon } from '@radix-ui/react-icons'; + +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import ColumnsContainerPropsSchema, { + type ColumnsContainerProps, +} from '../../../../documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema'; +import { PanelWrapper } from './helpers/panelWrapper'; +import { SliderInput } from './helpers/sliderInput'; +import { StyleInput } from './helpers/styleInput'; +import { ToggleGroupInput } from './helpers/toggleGroupInput'; + +type ColumnsContainerPanelProps = { + data: ColumnsContainerProps; + setData: (v: ColumnsContainerProps) => void; +}; +export default function ColumnsContainerPanel({ data, setData }: ColumnsContainerPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = ColumnsContainerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + updateData({ ...data, props: { ...data.props, columnsCount: Number(v) } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, fixedWidths } }); + }} + /> + + updateData({ ...data, props: { ...data.props, columnsGap } })} + /> + + , value: 'top' }, + { label: , value: 'middle' }, + { label: , value: 'bottom' }, + ]} + label={'Alignment'} + value={data.props?.contentAlignment ?? 'middle'} + onValueChange={(contentAlignment) => { + updateData({ ...data, props: { ...data.props, contentAlignment } }); + }} + /> + + updateData({ ...data, style })} + /> + + ); +} + +type TWidthValue = number | null | undefined; +type FixedWidths = [TWidthValue, TWidthValue, TWidthValue]; + +type ColumnsLayoutInputProps = { + value: FixedWidths | null | undefined; + onChange: (v: FixedWidths | null | undefined) => void; +}; + +function ColumnWidthsInput(props: ColumnsLayoutInputProps) { + const { value } = props; + let widths: FixedWidths = [null, null, null]; + if (!value) { + widths = [null, null, null]; + } else { + widths = value; + } + + return ( +
+ {widths.map((v, i) => { + return ( +
+ + { + const value = parseInt(e.target.value); + + const newWidths = [...widths]; + newWidths[i] = isNaN(value) ? null : value; + props.onChange(newWidths as FixedWidths); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ContainerSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ContainerSidebarPanel.tsx new file mode 100644 index 000000000..d48d4872f --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ContainerSidebarPanel.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +import ContainerPropsSchema, { type ContainerProps } from '../../../../documents/blocks/Container/ContainerPropsSchema'; + +import { PanelWrapper } from './helpers/panelWrapper'; +import { StyleInput } from './helpers/styleInput'; + +type ContainerSidebarPanelProps = { + data: ContainerProps; + setData: (v: ContainerProps) => void; +}; + +export default function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = ContainerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/DividerSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/DividerSidebarPanel.tsx new file mode 100644 index 000000000..f71650980 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/DividerSidebarPanel.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +import { type DividerProps, DividerPropsDefaults, DividerPropsSchema } from '@usewaypoint/block-divider'; + +import { ColorInput } from './helpers/colorInput'; +import { PanelWrapper } from './helpers/panelWrapper'; +import { SliderInput } from './helpers/sliderInput'; +import { StyleInput } from './helpers/styleInput'; + +type DividerSidebarPanelProps = { + data: DividerProps; + setData: (v: DividerProps) => void; +}; +export default function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) { + const [, setErrors] = useState(null); + const updateData = (d: unknown) => { + const res = DividerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor; + const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight; + + return ( + + updateData({ ...data, props: { ...data.props, lineColor } })} + /> + + updateData({ ...data, props: { ...data.props, lineHeight } })} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HeadingSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HeadingSidebarPanel.tsx new file mode 100644 index 000000000..dc602c00c --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HeadingSidebarPanel.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; + +import { type HeadingProps, HeadingPropsDefaults, HeadingPropsSchema } from '@usewaypoint/block-heading'; + +import { PanelWrapper } from './helpers/panelWrapper'; +import { StyleInput } from './helpers/styleInput'; +import { TextareaInput } from './helpers/textInput'; +import { ToggleGroupInput } from './helpers/toggleGroupInput'; + +type HeadingSidebarPanelProps = { + data: HeadingProps; + setData: (v: HeadingProps) => void; +}; +export default function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = HeadingPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + updateData({ ...data, props: { ...data.props, text } }); + }} + /> + + { + updateData({ ...data, props: { ...data.props, level } }); + }} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HtmlSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HtmlSidebarPanel.tsx new file mode 100644 index 000000000..ec52dfbb1 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/HtmlSidebarPanel.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +import { type HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html'; + +import { PanelWrapper } from './helpers/panelWrapper'; +import { StyleInput } from './helpers/styleInput'; +import { TextareaInput } from './helpers/textInput'; + +type HtmlSidebarPanelProps = { + data: HtmlProps; + setData: (v: HtmlProps) => void; +}; +export default function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = HtmlPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, props: { ...data.props, contents } })} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ImageSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ImageSidebarPanel.tsx new file mode 100644 index 000000000..17aafc213 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/ImageSidebarPanel.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import { type ImageProps, ImagePropsSchema } from '@usewaypoint/block-image'; + +import { AlignBottomIcon, AlignCenterVerticallyIcon, AlignTopIcon } from '@radix-ui/react-icons'; +import { PanelWrapper } from './helpers/panelWrapper'; +import { StyleInput } from './helpers/styleInput'; +import { TextInput } from './helpers/textInput'; +import { ToggleGroupInput } from './helpers/toggleGroupInput'; +import { Label } from '~/components/ui/label'; +import { Input } from '~/components/ui/input'; + +type ImageSidebarPanelProps = { + data: ImageProps; + setData: (v: ImageProps) => void; +}; +export default function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = ImagePropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + { + const v = e.target.value; + const url = v.trim().length === 0 ? null : v.trim(); + updateData({ ...data, props: { ...data.props, url } }); + }} + /> + + updateData({ ...data, props: { ...data.props, alt: e.target.value } })} + /> + + { + const v = e.target.value; + const linkHref = v.trim().length === 0 ? null : v.trim(); + updateData({ ...data, props: { ...data.props, linkHref } }); + }} + /> + +
+
+ + { + const value = parseInt(e.target.value); + updateData({ ...data, props: { ...data.props, width: isNaN(value) ? null : value } }); + }} + /> +
+
+ + { + const value = parseInt(e.target.value); + updateData({ ...data, props: { ...data.props, height: isNaN(value) ? null : value } }); + }} + /> +
+
+ + , value: 'top' }, + { label: , value: 'middle' }, + { label: , value: 'bottom' }, + ]} + label={'Alignment'} + value={data.props?.contentAlignment ?? 'middle'} + onValueChange={(contentAlignment) => { + updateData({ ...data, props: { ...data.props, contentAlignment } }); + }} + /> + + updateData({ ...data, style })} + /> +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/SpacerSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/SpacerSidebarPanel.tsx new file mode 100644 index 000000000..762b0eff3 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/SpacerSidebarPanel.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +import { type SpacerProps, SpacerPropsDefaults, SpacerPropsSchema } from '@usewaypoint/block-spacer'; + +import { PanelWrapper } from './helpers/panelWrapper'; +import { SliderInput } from './helpers/sliderInput'; + +type SpacerSidebarPanelProps = { + data: SpacerProps; + setData: (v: SpacerProps) => void; +}; +export default function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = SpacerPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, props: { ...data.props, height } })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/TextSidebarPanel.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/TextSidebarPanel.tsx new file mode 100644 index 000000000..6d8579db9 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/TextSidebarPanel.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +import { type TextProps, TextPropsSchema } from '@usewaypoint/block-text'; + +import { Label } from '~/components/ui/label'; +import { Switch } from '~/components/ui/switch'; +import { PanelWrapper } from './helpers/panelWrapper'; +import { StyleInput } from './helpers/styleInput'; +import { TextareaInput } from './helpers/textInput'; +import { BooleanInput } from './helpers/booleanInput'; + +type TextSidebarPanelProps = { + data: TextProps; + setData: (v: TextProps) => void; +}; +export default function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) { + const [, setErrors] = useState(null); + + const updateData = (d: unknown) => { + const res = TextPropsSchema.safeParse(d); + if (res.success) { + setData(res.data); + setErrors(null); + } else { + setErrors(res.error); + } + }; + + return ( + + updateData({ ...data, props: { ...data.props, text } })} + /> + + { + updateData({ ...data, props: { ...data.props, markdown } }); + }} + /> + + updateData({ ...data, style })} + /> + + ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/booleanInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/booleanInput.tsx new file mode 100644 index 000000000..2f8fb2a38 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/booleanInput.tsx @@ -0,0 +1,37 @@ +import { Label } from '~/components/ui/label'; +import { Switch } from '~/components/ui/switch'; +import { generateSlug } from '~/lib/utils'; + +type BooleanInputProps = { + label?: string; + value?: boolean | null; + onChange?: (value: boolean | null) => void; +}; + +/** + * @example + * ```tsx + * updateData({ ...data, props: { ...data.props, markdown } })} + * /> + * ``` + */ +export function BooleanInput(props: BooleanInputProps) { + const { label = 'Checked', value } = props; + const id = `boolean-input-${generateSlug(label)}`; + + return ( +
+ { + props.onChange?.(boolean); + }} + /> + +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/colorInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/colorInput.tsx new file mode 100644 index 000000000..1215c0816 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/colorInput.tsx @@ -0,0 +1,82 @@ +import { Plus, X } from 'lucide-react'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { generateSlug } from '~/lib/utils'; + +// Discriminated Union +type ColorInputProps = + // nullable + | { + label?: string; + nullable: true; + value?: string | null; + onChange?: (value: string | null) => void; + } + // not nullable + | { + label?: string; + nullable?: false; + value?: string; + onChange?: (value: string) => void; + }; + +/** + * @example + * ```tsx + * updateData({ ...data, borderColor })} + * /> + * ``` + */ +export function ColorInput(props: ColorInputProps) { + const { label = 'Color', nullable, value } = props; + const id = `color-input-${generateSlug(label)}`; + + return ( +
+ +
+
{ + if (props.nullable && !value) { + props.onChange?.('#000000'); + } + }} + > + { + if (!props.nullable) { + props.onChange?.(e.target.value); + } else { + props.onChange?.(e.target.value); + } + }} + /> + + {nullable && !value && ( + + )} +
+ + {nullable && value && ( + { + // Type has been narrowed by nullable + props.onChange?.(null); + }} + /> + )} +
+
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/fontFamilyInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/fontFamilyInput.tsx new file mode 100644 index 000000000..29dab3ada --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/fontFamilyInput.tsx @@ -0,0 +1,82 @@ +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '~/components/ui/button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '~/components/ui/command'; +import { Label } from '~/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'; +import { FONT_FAMILIES } from '~/documents/blocks/helpers/fontFamily'; +import { cn } from '~/lib/utils'; + +/** + * @example + * ```tsx + * + * updateData({ + * ...data, + * fontFamily: v, + * }) + * } + * /> + * ``` + */ +export function FontFamilyInput({ label = 'Font family', value = 'inherit', onChange = (value: string) => {} }) { + const [open, setOpen] = useState(false); + const [key, setKey] = useState(value); + + const fontFamiliesWithInherit = [ + { + label: 'Match email settings', + key: 'inherit', + value: 'inherit', + }, + ...FONT_FAMILIES, + ]; + + return ( +
+ + + + + + + + + + + No font found. + + {fontFamiliesWithInherit.map((font) => ( + { + setKey(currentKey === key ? '' : currentKey); + onChange(currentKey); + setOpen(false); + }} + > + + {font.label} + + + + ))} + + + + + +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/panelWrapper.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/panelWrapper.tsx new file mode 100644 index 000000000..e9e9b7ce2 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/panelWrapper.tsx @@ -0,0 +1,18 @@ +import { cn } from '~/lib/utils'; + +export function PanelWrapper({ + title = 'Panel', + className = '', + children, +}: { + title?: string; + className?: string; + children?: React.ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/sliderInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/sliderInput.tsx new file mode 100644 index 000000000..dc5a1cb6c --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/sliderInput.tsx @@ -0,0 +1,66 @@ +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { Slider } from '~/components/ui/slider'; +import { generateSlug } from '~/lib/utils'; + +/** + * @example + * ```tsx + * { + * updateData({ ...data, props: { ...data.props, size } }); + * }} + * /> + * ``` + */ +export function SliderInput({ + value = 0, + onChange = (v: number) => {}, + label = 'Slider', + min = 0, + max = 100, + step = 1, + unit = 'px', + labelHidden = false, + icon = null, +}: { + value?: number; + onChange?: (value: number) => void; + label?: string; + min?: number; + max?: number; + step?: number; + unit?: string; + labelHidden?: boolean; + icon?: React.ReactNode; +}) { + return ( +
+ {!labelHidden && ( + + )} +
+ {icon && {icon}} + onChange(value[0])} /> + onChange(Number(e.target.value))} + /> + {unit} +
+
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/styleInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/styleInput.tsx new file mode 100644 index 000000000..ce469fe3d --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/styleInput.tsx @@ -0,0 +1,202 @@ +import { AlignCenter, AlignLeft, AlignRight, AlignStartHorizontal } from 'lucide-react'; +import { Label } from '~/components/ui/label'; +import type { TStyle } from '~/documents/blocks/helpers/TStyle'; +import { cn } from '~/lib/utils'; +import { ColorInput } from './colorInput'; +import { ToggleGroupInput } from './toggleGroupInput'; +import { SliderInput } from './sliderInput'; +import { FontFamilyInput } from './fontFamilyInput'; + +type StyleInputProps = { + select: { style: keyof TStyle; label: string }[]; + className?: string; + value?: Partial | null; + onChange?(value: Partial): void; +}; + +/** + * @example + * ```tsx + * updateData({ ...data, style })} + * /> + */ +export function StyleInput(props: StyleInputProps) { + function handleStyleChange(key: keyof TStyle, value: T) { + const newStyle = { ...props.value, [key]: value }; + if (props.onChange) { + props.onChange(newStyle); + } + } + + return ( +
+ {props.select.map((item) => { + switch (item.style) { + case 'backgroundColor': + return ( + { + handleStyleChange('backgroundColor', backgroundColor); + }} + /> + ); + + case 'borderColor': + return ( + { + handleStyleChange('borderColor', borderColor); + }} + /> + ); + + case 'borderRadius': + return ( + { + handleStyleChange('borderRadius', borderRadius); + }} + /> + ); + + case 'color': + return ( + { + handleStyleChange('color', color); + }} + /> + ); + + case 'fontFamily': + return ( + { + handleStyleChange('fontFamily', v); + }} + /> + ); + + case 'fontSize': + return ( + { + handleStyleChange('fontSize', fontSize); + }} + /> + ); + + case 'fontWeight': + return ( + { + handleStyleChange('fontWeight', fontWeight); + }} + /> + ); + + case 'padding': { + const paddingValue = props.value?.padding || { top: 0, bottom: 0, left: 0, right: 0 }; + const icons = { + top: , + bottom: , + left: , + right: , + }; + + return ( +
+ +
+
+ {Object.entries(paddingValue).map(([k, v]) => { + return ( + { + handleStyleChange('padding', { + ...props.value?.padding, + [k]: value, + }); + }} + labelHidden + icon={icons[k as keyof typeof icons]} + /> + ); + })} +
+
+
+ ); + } + + case 'textAlign': + return ( + , value: 'left' }, + { label: , value: 'center' }, + { label: , value: 'right' }, + ]} + label={item.label} + value={props.value?.textAlign ?? 'left'} + onValueChange={(textAlign) => { + handleStyleChange('textAlign', textAlign); + }} + /> + ); + } + })} +
+ ); +} diff --git a/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/textInput.tsx b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/textInput.tsx new file mode 100644 index 000000000..9f515cf33 --- /dev/null +++ b/packages/react-router-v7-example/app/components/inspector-sidebar/inspectPanel/panels/helpers/textInput.tsx @@ -0,0 +1,55 @@ +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { Textarea } from '~/components/ui/textarea'; +import { cn } from '~/lib/utils'; + +type TextInputProps = React.ComponentProps & { + label?: string; +}; + +/** + * @example + * ```tsx + * { + * updateData({ ...data, props: { ...data.props, text: e.target.value } }); + * }} + * /> + * ``` + */ +export function TextInput({ label, className, ...props }: TextInputProps) { + return ( +
+ + +
+ ); +} + +type TextareaInputProps = React.ComponentProps & { + label?: string; + onChange?: (v: string) => void; +}; + +/** + * @example + * ```tsx + * { + * updateData({ ...data, props: { ...data.props, text: e.target.value } }); + * }} + * /> + * ``` + */ +export function TextareaInput({ label, onChange, className, ...props }: TextareaInputProps) { + return ( +
+ +