feat: Contentstack <-> ImageVault integration

This commit is contained in:
Michael Zetterberg
2024-03-25 11:38:14 +01:00
parent 920cbf241a
commit a706b9cf8a
39 changed files with 16647 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import React, { PropsWithChildren } from 'react';
import { Tooltip } from '@contentstack/venus-components';
type EmbedBtnProps = PropsWithChildren & {
content: string;
onClick: (e: unknown) => void;
title: string;
};
export default function EmbedBtn({
content,
title,
onClick,
children,
}: EmbedBtnProps) {
return (
<Tooltip position="bottom" content={content}>
<button
id={title}
type="button"
onClick={(e) => {
e?.preventDefault();
e?.stopPropagation();
onClick(e);
}}
>
{children}
</button>
</Tooltip>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect, ChangeEvent } from 'react';
import {
ModalFooter,
ModalBody,
ModalHeader,
ButtonGroup,
Button,
Field,
FieldLabel,
TextInput,
Select,
} from '@contentstack/venus-components';
import { Path } from 'slate';
import type {
IRteParam,
IRteElementType,
} from '@contentstack/app-sdk/dist/src/RTE/types';
import type { InsertResponse } from "~/types/imagevault";
enum DropdownValues {
center = "center",
left = "left",
right = "right",
none = "none",
}
type DropDownItem = {
label: string;
value: DropdownValues;
type: string;
};
const dropdownList: DropDownItem[] = [
{
label: "None",
value: DropdownValues.none,
type: "select",
},
{
label: "Center",
value: DropdownValues.center,
type: "select",
},
{
label: "Left",
value: DropdownValues.left,
type: "select",
},
{
label: "Right",
value: DropdownValues.right,
type: "select",
},
];
type ImageEditModalProps = {
element: IRteElementType & {
attrs: InsertResponse;
};
rte: IRteParam;
closeModal: () => void;
path: Path;
};
export default function ImageEditModal({
element,
closeModal,
path,
rte,
}: ImageEditModalProps) {
const [alignment, setAlignment] = useState<DropDownItem>({
label: "None",
value: DropdownValues.none,
type: "select",
});
const [altText, setAltText] = useState("");
const [caption, setCaption] = useState("");
const assetUrl = element.attrs.MediaConversions[0].Url;
useEffect(() => {
if (element.attrs.Metadata && element.attrs.Metadata.length) {
const altText = element.attrs.Metadata.find((meta) =>
meta.Name.includes("AltText_")
)?.Value;
const caption = element.attrs.Metadata.find((meta) =>
meta.Name.includes("Title_")
)?.Value;
setAltText(altText ?? "");
setCaption(caption ?? "");
}
}, [element.attrs.Metadata]);
function handleSave() {
let newStyle;
switch (alignment.value) {
case DropdownValues.center:
case DropdownValues.left:
case DropdownValues.right:
newStyle = {
textAlign: alignment.value,
maxWidth: element.attrs.width
? `${element.attrs.width}px`
: undefined,
};
break;
case DropdownValues.none:
default:
newStyle = {};
break;
}
const metaData = element.attrs.Metadata ?? [];
const newMetadata = metaData.map((meta) => {
if (meta.Name.includes("AltText_")) {
return { ...meta, Value: altText };
}
if (meta.Name.includes("Title_")) {
return { ...meta, Value: caption };
}
return meta;
});
rte._adv.Transforms?.setNodes<IRteElementType>(
rte._adv.editor,
{
attrs: {
...element.attrs,
Metadata: newMetadata,
position: alignment.value,
style: { ...element.attrs.style, ...newStyle },
},
},
{ at: path }
);
closeModal();
}
return (
<>
<ModalHeader title="Update image" closeModal={closeModal} />
<ModalBody
style={{
display: "flex",
gap: "1rem",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
}}
>
<div style={{ flex: 1, overflowY: "auto" }}>
<img
src={assetUrl}
alt={altText}
height="100%"
style={{ maxHeight: "345px" }}
/>
</div>
<div style={{ flex: 1 }}>
<Field>
<Select
selectLabel="Alignment"
value={alignment}
onChange={(e: DropDownItem) => {
setAlignment(e);
}}
options={dropdownList}
/>
</Field>
<Field>
<FieldLabel htmlFor="alt">Alt text</FieldLabel>
<TextInput
value={altText}
placeholder="Alt text for image"
name="alt"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setAltText(e.target.value)
}
/>
</Field>
<Field>
<FieldLabel htmlFor="caption">Caption</FieldLabel>
<TextInput
value={caption}
placeholder="Caption for image..."
name="caption"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCaption(e.target.value)
}
/>
</Field>
</div>
</ModalBody>
<ModalFooter>
<ButtonGroup>
<Button onClick={closeModal} buttonType="light">
Cancel
</Button>
<Button onClick={handleSave} icon="SaveWhite">
Save
</Button>
</ButtonGroup>
</ModalFooter>
</>
);
}

View File

@@ -0,0 +1,173 @@
import React, { useRef, useCallback, PropsWithChildren } from 'react';
import { Tooltip, Icon, cbModal } from '@contentstack/venus-components';
import { Resizable } from 're-resizable';
import EmbedBtn from './EmbedBtn';
import ImageEditModal from './ImageEditModal';
import type {
IRteParam,
IRteElementType,
} from '@contentstack/app-sdk/dist/src/RTE/types';
import type { InsertResponse } from '~/types/imagevault';
type ImageElementProps = PropsWithChildren & {
element: IRteElementType & { attrs: InsertResponse };
rte: IRteParam;
};
export function ImageElement({ children, element, rte }: ImageElementProps) {
const assetUrl = element.attrs.MediaConversions[0].Url;
const isSelected = rte?.selection?.isSelected();
const isFocused = rte?.selection?.isFocused();
const isHighlight = isFocused && isSelected;
const imgRef = useRef<HTMLDivElement | null>(null);
const handleEdit = useCallback(() => {
cbModal({
// @ts-expect-error: Component is badly typed
component: (compProps) =>
ImageEditModal({
element,
rte,
path: rte.getPath(element),
...compProps,
}),
modalProps: {
size: "max",
},
});
}, [element, rte]);
const ToolTipButtons = () => {
return (
<div contentEditable={false} className="embed--btn-group">
<EmbedBtn title="edit" content="Edit" onClick={handleEdit}>
<Icon icon="Rename" />
</EmbedBtn>
<EmbedBtn
title="remove"
content={"Remove"}
onClick={() => rte?.removeNode(element)}
>
<Icon icon="Trash" />
</EmbedBtn>
</div>
);
};
const onResizeStop = () => {
const { attrs: elementAttrs } = element;
const { offsetWidth: width, offsetHeight: height } = imgRef?.current ?? {};
const newAttrs: { [key: string]: unknown } = {
...elementAttrs,
style: {
...(elementAttrs?.style ?? {}),
"max-width": `${width}px`,
},
...(width && height
? { width: `${width.toString()}px`, height: `${height.toString()}px` }
: {}),
};
rte?._adv?.Transforms?.setNodes<IRteElementType>(
rte._adv.editor,
{ attrs: newAttrs },
{ at: rte.getPath(element) }
);
};
let alignmentStyles = {};
const marginAlignment: Record<string, { [key: string]: string }> = {
center: { margin: "auto" },
left: { marginRight: "auto" },
right: { marginLeft: "auto" },
};
if (typeof element.attrs.position === "string") {
alignmentStyles = marginAlignment[element.attrs.position];
}
return (
<div
style={{
...alignmentStyles,
...element.attrs.style,
}}
>
<Tooltip
zIndex={909}
className="p-0"
style={{ marginBottom: "10px" }}
position="top"
variantType="light"
offset={[0, -15]}
content={<ToolTipButtons />}
>
<span
data-type="asset"
contentEditable={false}
style={element.attrs?.style}
>
<Resizable
lockAspectRatio
size={{
width: element.attrs.width ?? "180px",
height: element.attrs.heigth ?? "auto",
}}
onResizeStop={onResizeStop}
handleStyles={{
right: { right: 0, width: "15px" },
left: { left: 0, width: "15px" },
bottom: { bottom: "0" },
bottomRight: { width: "15px" },
}}
>
<div
ref={imgRef}
contentEditable={false}
style={{
width: "100%",
height: "100%",
...(isHighlight ? { border: "1px solid #6c5ce7" } : {}),
}}
>
<img
src={assetUrl}
onError={(event) => {
event.currentTarget.src = "https://placehold.co/600x400";
}}
style={{
width: "100%",
height: "auto",
aspectRatio: element.attrs.MediaConversions[0].AspectRatio,
borderRadius: "8px",
}}
alt={element.attrs.altText}
title={element.attrs?.Name}
/>
<div
style={{
position: "absolute",
bottom: 0,
right: 0,
background: "#fff",
color: "#000",
height: "25px",
padding: "3px",
borderRadius: "4px",
}}
>
<Icon icon="Embed" />
</div>
</div>
</Resizable>
{children}
</span>
</Tooltip>
</div>
);
}