diff --git a/package.json b/package.json index b99e6be..44cf909 100644 --- a/package.json +++ b/package.json @@ -72,13 +72,13 @@ "@eslint/js": "^9", "@mui/icons-material": "^6.4.7", "@mui/material": "^6.4.7", - "@types/react": "18", - "@types/react-dom": "18", + "@types/react": "19.1.5", + "@types/react-dom": "19.1.5", "date-fns": "^2.30.0", "eslint": "^9", "eslint-config-prettier": "^9", "eslint-plugin-react-hooks": "^5", - "@m10c/mui-kit": "^0.0.1", + "@m10c/mui-kit": "^0.0.2", "prettier": "^3.5.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/components/BlocksField.tsx b/src/components/BlocksField.tsx index 483760b..81c9a86 100644 --- a/src/components/BlocksField.tsx +++ b/src/components/BlocksField.tsx @@ -1,12 +1,31 @@ 'use client'; -import { Card, CardContent, Divider, Stack, Typography } from '@mui/material'; -import { FieldText } from '@m10c/mui-kit'; +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import { + Box, + Button, + Card, + CardContent, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + InputAdornment, + Stack, + Typography, +} from '@mui/material'; +import { FieldRadioGroup, FieldText } from '@m10c/mui-kit'; import React from 'react'; import { FieldProp } from 'react-typed-form'; import type { Block, + BlockFieldPreviews, BlockFieldRenderer, BlockFieldRenderers, BlockType, @@ -16,13 +35,27 @@ import type { SimpleField, } from '../types'; +/** Icons a consumer can swap for its own icon set. */ +export type ListCardIcons = { + drag?: React.ReactNode; + edit?: React.ReactNode; +}; + type Props = { blockTypes: readonly BlockTypeInput[]; field: FieldProp; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; }; -export default function BlocksField({ blockTypes, field, renderers }: Props) { +export default function BlocksField({ + blockTypes, + field, + renderers, + previews, + icons, +}: Props) { const blocks = field.value ?? []; // The boundary types `fields` as `unknown` (see BlockTypeInput); the BE sends // the rich field metadata, so narrow to BlockType here, the single point of @@ -49,6 +82,8 @@ export default function BlocksField({ blockTypes, field, renderers }: Props) { block={block} blockType={blockTypesByKey[block.type]} renderers={renderers} + previews={previews} + icons={icons} onChange={(next) => updateBlock(index, next)} /> ))} @@ -60,32 +95,81 @@ type BlockCardProps = { block: Block; blockType: BlockType | undefined; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; onChange: (next: Block) => void; }; -function BlockCard({ block, blockType, renderers, onChange }: BlockCardProps) { +function BlockCard({ + block, + blockType, + renderers, + previews, + icons, + onChange, +}: BlockCardProps) { function updateData(key: string, value: unknown) { onChange({ ...block, data: { ...block.data, [key]: value } }); } - return ( - - - - - {blockType?.label ?? `Unknown block: ${block.type}`} - - {blockType ? ( - Object.entries(blockType.fields).map(([key, fieldDef]) => ( - + fieldDef.kind === 'list' ? (fieldDef.headerFieldKeys ?? []) : [], + ), + ); + + /** The fields a list field claims for its heading, rendered in schema order. */ + function renderHeaderFields(fieldDef: BlockTypeField) { + if (fieldDef.kind !== 'list' || !fieldDef.headerFieldKeys?.length) { + return undefined; + } + const claimed = fieldDef.headerFieldKeys; + return ( + <> + {Object.entries(fields) + .filter(([key]) => claimed.includes(key)) + .map(([key, headerFieldDef]) => + headerFieldDef.kind === 'list' ? null : ( + updateData(key, value)} /> - )) + ), + )} + + ); + } + + return ( + + + + {!blockType?.hideLabel && ( + + {blockType?.label ?? `Unknown block: ${block.type}`} + + )} + {blockType ? ( + Object.entries(blockType.fields) + .filter(([key]) => !headerFieldKeys.has(key)) + .map(([key, fieldDef]) => ( + updateData(key, value)} + /> + )) ) : ( No schema registered for block type "{block.type}". @@ -102,6 +186,9 @@ type BlockFieldRendererProps = { fieldDef: BlockTypeField; value: unknown; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + headerSlot?: React.ReactNode; onChange: (value: unknown) => void; }; @@ -110,6 +197,9 @@ function BlockFieldRenderer({ fieldDef, value, renderers, + previews, + icons, + headerSlot, onChange, }: BlockFieldRendererProps) { if (fieldDef.kind === 'list') { @@ -118,6 +208,9 @@ function BlockFieldRenderer({ fieldDef={fieldDef} value={Array.isArray(value) ? value : []} renderers={renderers} + previews={previews} + icons={icons} + headerSlot={headerSlot} onChange={onChange} /> ); @@ -140,7 +233,7 @@ type SimpleFieldRendererProps = { renderers?: BlockFieldRenderers; /** Overrides the rendered label (used to suffix a list item's index). */ labelOverride?: string; - onChange: (value: string | null) => void; + onChange: (value: unknown) => void; }; function SimpleFieldRenderer({ @@ -151,8 +244,13 @@ function SimpleFieldRenderer({ labelOverride, onChange, }: SimpleFieldRendererProps) { + const label = labelOverride ?? fieldLabel(fieldDef, fieldKey); + + if (fieldDef.kind === 'note') { + return ; + } + const stringValue = typeof value === 'string' ? value : null; - const label = labelOverride ?? fieldDef.label ?? fieldKey; const customRenderer: BlockFieldRenderer | undefined = renderers?.[fieldDef.kind]; @@ -163,12 +261,40 @@ function SimpleFieldRenderer({ name: fieldKey, label, value: stringValue, + features: fieldDef.features, + hint: fieldDef.hint, + prefix: fieldDef.prefix, + values: stringValues(value), + maxItems: fieldDef.maxItems, onChange, + onChangeValues: onChange, })} ); } + if (fieldDef.kind === 'choice') { + return ( + + + + ); + } + + // Uploading belongs to the consuming app, so an images field draws nothing + // of its own until a renderer is given for it. + if (fieldDef.kind === 'images') { + return ; + } + const fieldProp: FieldProp = { name: fieldKey, label, @@ -182,42 +308,120 @@ function SimpleFieldRenderer({ fieldDef.kind === 'richtext'; return ( - - {label} + + {fieldDef.prefix} + + ), + } + : undefined + } inputProps={ fieldDef.maxLength ? { maxLength: fieldDef.maxLength } : undefined } /> + + ); +} + +type FieldWrapProps = { + fieldDef: SimpleField; + label: string; + children?: React.ReactNode; +}; + +/** A field's label and hint, above whatever draws its value. */ +function FieldWrap({ fieldDef, label, children }: FieldWrapProps) { + return ( + + + {label} + {fieldDef.hint && ( + + {fieldDef.hint} + + )} + + {children} ); } +/** Names a part of the page an admin cannot edit, e.g. a contact form. */ +function FieldNote({ + fieldDef, + label, +}: { + fieldDef: SimpleField; + label: string; +}) { + return ( + + {fieldDef.label && {label}} + + {fieldDef.text} + + + ); +} + +/** The strings held by a field with several values, e.g. uploaded images. */ +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +/** A field's label, marked with an asterisk when it is required. */ +function fieldLabel(fieldDef: BlockTypeField, fallback: string) { + return `${fieldDef.label ?? fallback}${fieldDef.required ? '*' : ''}`; +} + +/** What a saved value reads as, naming the choice it stands for. */ +function optionLabel(fieldDef: SimpleField, value: string | null) { + const option = fieldDef.options?.find((item) => item.value === value); + return option?.label ?? value; +} + type ListItem = Record; type ListFieldRendererProps = { fieldDef: ListField; value: ListItem[]; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + headerSlot?: React.ReactNode; onChange: (value: ListItem[]) => void; }; +function ListFieldRenderer(props: ListFieldRendererProps) { + return props.fieldDef.variant === 'cards' ? ( + + ) : ( + + ); +} + /** - * Renders a fixed list of items inline (count comes from the BE data, no - * add/remove). Each item's fields are shown with an index-suffixed label and - * separated by a divider. + * Lists every item's fields one after another, separated by a divider. The + * number of items comes from the data, so there is nothing to add or delete. */ -function ListFieldRenderer({ +function ListInline({ fieldDef, - value, + value: items, renderers, onChange, }: ListFieldRendererProps) { - const items = value; const itemLabel = fieldDef.itemLabel ?? fieldDef.label ?? 'Item'; function updateItem(index: number, next: ListItem) { @@ -238,7 +442,7 @@ function ListFieldRenderer({ fieldDef={subFieldDef} value={item[subKey]} renderers={renderers} - labelOverride={`${subFieldDef.label ?? subKey} ${itemLabel} ${index + 1}`} + labelOverride={`${fieldLabel(subFieldDef, subKey)} ${itemLabel} ${index + 1}`} onChange={(subValue) => updateItem(index, { ...item, [subKey]: subValue }) } @@ -249,3 +453,320 @@ function ListFieldRenderer({ ); } + +/** + * Shows each item as a summary card that opens a dialog to edit. Items can be + * added until `maxItems` is reached, and deleted while more than `minItems` + * remain. + */ +function ListCards({ + fieldDef, + value: items, + renderers, + previews, + icons, + headerSlot, + onChange, +}: ListFieldRendererProps) { + const [editedIndex, setEditedIndex] = React.useState(null); + const [isAdding, setIsAdding] = React.useState(false); + const [draggedIndex, setDraggedIndex] = React.useState(null); + + const itemLabel = fieldDef.itemLabel ?? 'item'; + const editedItem = editedIndex === null ? undefined : items[editedIndex]; + const { maxItems, minItems } = fieldDef; + const isFull = maxItems !== undefined && items.length >= maxItems; + const canDelete = items.length > (minItems ?? 0); + // A list whose length is fixed has nothing to add or count. + const isFixedLength = minItems !== undefined && minItems === maxItems; + + function replaceItem(index: number, next: ListItem) { + const updated = items.slice(); + updated[index] = next; + onChange(updated); + } + + function deleteItem(index: number) { + onChange(items.filter((_, i) => i !== index)); + } + + function moveItem(from: number, to: number) { + const updated = items.slice(); + const [moved] = updated.splice(from, 1); + if (moved === undefined) return; + updated.splice(to, 0, moved); + onChange(updated); + } + + return ( + + {!isFixedLength && ( + + + {fieldLabel(fieldDef, itemLabel)} + + + + )} + + {maxItems !== undefined && !isFixedLength && ( + + {items.length} of {maxItems} {itemLabel}(s) added. You can add up to{' '} + {maxItems} {itemLabel}s. + + )} + + {headerSlot} + + {items.map((item, index) => ( + setEditedIndex(index)} + onDragStart={() => setDraggedIndex(index)} + onDragEnd={() => setDraggedIndex(null)} + onDrop={() => { + if (draggedIndex !== null && draggedIndex !== index) { + moveItem(draggedIndex, index); + } + setDraggedIndex(null); + }} + /> + ))} + + {editedIndex !== null && editedItem !== undefined && ( + { + deleteItem(editedIndex); + setEditedIndex(null); + } + : undefined + } + onConfirm={(next) => { + replaceItem(editedIndex, next); + setEditedIndex(null); + }} + onClose={() => setEditedIndex(null)} + /> + )} + + {isAdding && ( + { + onChange([...items, next]); + setIsAdding(false); + }} + onClose={() => setIsAdding(false)} + /> + )} + + ); +} + +type ListItemCardProps = { + fieldDef: ListField; + item: ListItem; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + onEdit: () => void; + onDragStart: () => void; + onDragEnd: () => void; + onDrop: () => void; +}; + +function ListItemCard({ + fieldDef, + item, + previews, + icons, + onEdit, + onDragStart, + onDragEnd, + onDrop, +}: ListItemCardProps) { + // Only the handle starts a drag, so text inside the card stays selectable. + const [isDraggable, setIsDraggable] = React.useState(false); + + return ( + { + setIsDraggable(false); + onDragEnd(); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={onDrop} + sx={{ p: 2, borderRadius: 1, bgcolor: 'grey.100' }} + > + setIsDraggable(true)} + onMouseUp={() => setIsDraggable(false)} + sx={{ display: 'flex', color: 'primary.main', cursor: 'grab' }} + > + {icons?.drag ?? } + + + {Object.entries(fieldDef.itemFields).map(([subKey, subFieldDef]) => { + const label = fieldLabel(subFieldDef, subKey); + if (subFieldDef.kind === 'note') { + return ( + + ); + } + const value = typeof item[subKey] === 'string' ? item[subKey] : null; + const preview = previews?.[subFieldDef.kind]; + return ( + + + {label} + + {preview ? ( + preview(value) + ) : ( + + {optionLabel(subFieldDef, value)} + + )} + + ); + })} + + + {icons?.edit ?? } + + + ); +} + +type ListItemDialogProps = { + title: string; + fieldDef: ListField; + item: ListItem; + renderers?: BlockFieldRenderers; + confirmLabel: string; + onDelete?: () => void; + onConfirm: (item: ListItem) => void; + onClose: () => void; +}; + +function ListItemDialog({ + title, + fieldDef, + item, + renderers, + confirmLabel, + onDelete, + onConfirm, + onClose, +}: ListItemDialogProps) { + const [draft, setDraft] = React.useState(item); + + return ( + + + {title} + + + + + + + {!!fieldDef.variables?.length && ( + + Variables to use + } + > + {fieldDef.variables.map((variable) => ( + + {variable.token} + + {variable.description} + + + ))} + + + )} + {Object.entries(fieldDef.itemFields).map(([subKey, subFieldDef]) => ( + + setDraft({ ...draft, [subKey]: subValue }) + } + /> + ))} + + + + {onDelete ? ( + <> + + + + ) : ( + + )} + + + ); +} diff --git a/src/components/PageEditor.tsx b/src/components/PageEditor.tsx index 9f4c534..f623e65 100644 --- a/src/components/PageEditor.tsx +++ b/src/components/PageEditor.tsx @@ -18,11 +18,22 @@ import React from 'react'; import { FieldProp } from 'react-typed-form'; import usePreviewSender from '../hooks/use-preview-sender'; -import BlocksField from './BlocksField'; -import type { Block, BlockFieldRenderers, BlockTypeInput } from '../types'; +import BlocksField, { type ListCardIcons } from './BlocksField'; +import type { + Block, + BlockFieldPreviews, + BlockFieldRenderers, + BlockTypeInput, +} from '../types'; type PreviewWidth = 'desktop' | 'tablet' | 'mobile'; +const DEVICE_LABELS: Record = { + desktop: 'Desktop', + tablet: 'Tablet', + mobile: 'Mobile', +}; + const PREVIEW_WIDTHS = { desktop: 1280, tablet: 768, @@ -36,6 +47,10 @@ type Props = { blockTypes: readonly BlockTypeInput[]; field: FieldProp; renderers?: BlockFieldRenderers; + previews?: BlockFieldPreviews; + icons?: ListCardIcons; + /** Names for the preview's device sizes, e.g. 'Mobile website'. */ + deviceLabels?: Partial>; /** Site origin for the preview iframe and postMessage target. */ previewUrl: string; pagePath: string; @@ -50,6 +65,9 @@ export default function PageEditor({ blockTypes, field, renderers, + previews, + icons, + deviceLabels, previewUrl, pagePath, previewContent, @@ -105,6 +123,8 @@ export default function PageEditor({ blockTypes={blockTypes} field={field} renderers={renderers} + previews={previews} + icons={icons} /> @@ -134,9 +154,13 @@ export default function PageEditor({ IconComponent={KeyboardArrowDown} sx={{ bgcolor: 'background.paper' }} > - Desktop - Tablet - Mobile + {(Object.keys(DEVICE_LABELS) as PreviewWidth[]).map( + (device) => ( + + {deviceLabels?.[device] ?? DEVICE_LABELS[device]} + + ), + )} @@ -179,36 +203,16 @@ function PreviewIframe({ src: string; renderWidth: number; }) { - const containerRef = React.useRef(null); - const [containerWidth, setContainerWidth] = React.useState(0); - - React.useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - setContainerWidth(entry.contentRect.width); - }); - - observer.observe(container); - return () => observer.disconnect(); - }, []); - if (!src) return null; - const scale = - containerWidth > 0 ? Math.min(1, containerWidth / renderWidth) : 0.5; - + // The site renders at its real width, so a device wider than the pane is + // scrolled to rather than scaled down. return ( - + @@ -217,12 +221,10 @@ function PreviewIframe({ src={src} title="Page preview" style={{ - width: renderWidth, - height: `${Math.round(100 / scale)}%`, + width: '100%', + height: '100%', border: 'none', backgroundColor: 'white', - transformOrigin: 'top left', - transform: `scale(${scale})`, display: 'block', borderRadius: 8, }} diff --git a/src/components/SeoEditor.tsx b/src/components/SeoEditor.tsx index 5cdf140..ca44b1f 100644 --- a/src/components/SeoEditor.tsx +++ b/src/components/SeoEditor.tsx @@ -5,12 +5,18 @@ import { FieldText } from '@m10c/mui-kit'; import type React from 'react'; import type { FieldProp } from 'react-typed-form'; +import type { BlockFieldRenderers } from '../types'; + type Props = { pageTitleField: FieldProp; descriptionField: FieldProp; + /** Draws the text fields, for an app whose inputs look nothing like these. */ + renderers?: BlockFieldRenderers; imageField?: FieldProp; renderImageField?: (field: FieldProp) => React.ReactNode; imagePreviewUrl?: string; + /** The image the site shares when a page names none of its own. */ + fallbackImageUrl?: string; /** The site's favicon, shown next to the URL in the search preview (search * engines use the favicon here, not the social/OG image). */ faviconUrl?: string; @@ -20,6 +26,12 @@ type Props = { onPublish: () => void; }; +/** Diameter of the circle search engines draw the favicon inside. */ +const FAVICON_CHIP_SIZE = 26; + +/** The favicon itself, inset so it stays within the circle. */ +const FAVICON_SIZE = 20; + function CharacterCount({ value, min, @@ -50,9 +62,11 @@ function CharacterCount({ export default function SeoEditor({ pageTitleField, descriptionField, + renderers, imageField, renderImageField, imagePreviewUrl, + fallbackImageUrl, faviconUrl, fallbackTitle, siteName = '', @@ -61,6 +75,7 @@ export default function SeoEditor({ }: Props) { const pageTitle = pageTitleField.value ?? ''; const description = descriptionField.value ?? ''; + const socialImageUrl = imagePreviewUrl ?? fallbackImageUrl; return ( - Page Title - + {renderers?.text ? ( + renderers.text({ + name: 'pageTitle', + label: 'Page Title', + value: pageTitleField.value ?? null, + onChange: pageTitleField.handleValueChange, + }) + ) : ( + <> + Page Title + + + )} - Description - + {renderers?.textarea ? ( + renderers.textarea({ + name: 'description', + label: 'Description', + value: descriptionField.value ?? null, + onChange: descriptionField.handleValueChange, + }) + ) : ( + <> + Description + + + )} {imageField && renderImageField && renderImageField(imageField)} @@ -118,15 +155,15 @@ export default function SeoEditor({ ; }; @@ -23,6 +66,8 @@ export type BlockTypeField = SimpleField | ListField; export type BlockType = { key: string; label: string; + /** Hides the block's heading, for a block whose single field is titled. */ + hideLabel?: boolean; fields: Record; }; @@ -48,7 +93,19 @@ export type BlockFieldRendererProps = { name: string; label: string; value: string | null; + /** The formats the field's schema allows, e.g. ['bold', 'link']. */ + features?: string[]; + /** Guidance under the label, e.g. the dimensions an image should have. */ + hint?: string; + /** Fixed text before the input, e.g. a currency symbol. */ + prefix?: string; + /** The saved values of a field that holds several, e.g. `images`. */ + values?: string[]; + /** Caps how many values a field that holds several accepts. */ + maxItems?: number; onChange: (value: string | null) => void; + /** Replaces the values of a field that holds several. */ + onChangeValues?: (values: string[]) => void; }; export type BlockFieldRenderer = ( @@ -58,3 +115,13 @@ export type BlockFieldRenderer = ( export type BlockFieldRenderers = Partial< Record >; + +/** + * Draws a field's saved value inside a card summary, e.g. the icon a slug + * names. Without one the value is shown as text. + */ +export type BlockFieldPreview = (value: string | null) => React.ReactNode; + +export type BlockFieldPreviews = Partial< + Record +>; diff --git a/yarn.lock b/yarn.lock index a5cd40f..65b94d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -449,10 +449,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@m10c/mui-kit@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@m10c/mui-kit/-/mui-kit-0.0.1.tgz#fe00038afc166a644b323fc283e08b4a9b462d56" - integrity sha512-ZZmGsH6cdyfjbtA4GlicZ8gn3PqzFow2GaRCXIz5U0xXRdAJWmIjFFFIV+/Iyka0TGFmRq3oNSAcQNjA5c4VUA== +"@m10c/mui-kit@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@m10c/mui-kit/-/mui-kit-0.0.2.tgz#0233d1d3e0cdbc1071ede9ed581d059e48a7dfbf" + integrity sha512-9rmyymyqOQ+7UO4di5RqfDE2Bec04W7Id9tM/Ox+vY8ZK778hEwMcZwoK5/kvueOaxUzmlCm96tMGcmi4edvTQ== dependencies: "@phosphor-icons/react" "^2.1.7" @@ -688,28 +688,27 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prop-types@*", "@types/prop-types@^15.7.14": +"@types/prop-types@^15.7.14": version "15.7.15" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== -"@types/react-dom@18": - version "18.3.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" - integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== +"@types/react-dom@19.1.5": + version "19.1.5" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.5.tgz#cdfe2c663742887372f54804b16e8dbc26bd794a" + integrity sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg== "@types/react-transition-group@^4.4.12": version "4.4.12" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@18": - version "18.3.31" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.31.tgz#b5e95e28ffcceab8d982f33f2eb076e17653c2a4" - integrity sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw== +"@types/react@19.1.5": + version "19.1.5" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.5.tgz#9feb3bdeb506d0c79d8533b6ebdcacdbcb4756db" + integrity sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g== dependencies: - "@types/prop-types" "*" - csstype "^3.2.2" + csstype "^3.0.2" "@typescript-eslint/eslint-plugin@8.65.0": version "8.65.0" @@ -992,7 +991,7 @@ cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" -csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2: +csstype@^3.0.2, csstype@^3.1.3: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==