diff --git a/packages/app/package.json b/packages/app/package.json index c2d73fbc..c53db1fe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -35,7 +35,7 @@ "@mui/material": "^5.5.0", "@mui/styles": "^5.5.0", "@nanostores/react": "ai/react", - "@tiptap/extension-code-block-lowlight": "^2.4.0", + "@tiptap/html": "^2.4.0", "@types/dompurify": "^3.0.5", "@web3modal/ethers5": "^4.1.11", "ai": "^3.1.12", @@ -47,10 +47,6 @@ "class-variance-authority": "^0.7.0", "datastore-core": "^9.0.0", "dompurify": "^3.1.4", - "draft-convert": "^2.1.13", - "draft-js": "^0.11.7", - "draft-js-export-html": "^1.4.1", - "draft-js-import-html": "^1.4.1", "ethers": "^5.7.2", "gh-pages": "^3.2.3", "graphql": "^16.3.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 6a5a1775..9a2b6207 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react" +import React, { useEffect, useState } from "react" import { PublicationView } from "@/components/views/publication/PublicationView" import { Routes, Route, useLocation } from "react-router-dom" import { SnackbarProvider } from "notistack" @@ -29,7 +29,6 @@ const App: React.FC = () => { useEffect(() => { const pathParts = location.pathname.split("-") let urlChainId = pathParts[0].replace("/", "") - console.log("urlChainId", urlChainId) const validChainIds = CHAINS.map((chain) => chain.id.toString()) if (!validChainIds.includes(urlChainId) && userChainId) { @@ -59,18 +58,18 @@ const App: React.FC = () => { } /> } /> } /> - {/* } /> + } /> } /> } /> } /> } /> - } /> */} + } /> } /> - {/* } /> + } /> + } /> } /> - } /> - } /> */} + } /> diff --git a/packages/app/src/components/commons/DeterministicAvatar/index.tsx b/packages/app/src/components/commons/DeterministicAvatar/index.tsx index b058df5d..312c0b95 100644 --- a/packages/app/src/components/commons/DeterministicAvatar/index.tsx +++ b/packages/app/src/components/commons/DeterministicAvatar/index.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef } from "react" import Sketch from "react-p5" import p5Types from "p5" +//@ts-ignore import Random, { genTokenData } from "./utils" import { Box } from "@mui/material" import { Piece } from "./Piece" diff --git a/packages/app/src/components/commons/Editor/AdvanceEditor.tsx b/packages/app/src/components/commons/Editor/AdvanceEditor.tsx index 900d4867..b669294a 100644 --- a/packages/app/src/components/commons/Editor/AdvanceEditor.tsx +++ b/packages/app/src/components/commons/Editor/AdvanceEditor.tsx @@ -123,7 +123,7 @@ const useStyles = makeStyles((theme: any) => ({ const Editor: React.FC = ({ initialValue, onChange, onHtml }) => { const classes = useStyles() const extensions = useExtensions() - const [saveStatus, setSaveStatus] = useState("Saved") + // const [saveStatus, setSaveStatus] = useState("Saved") const [openNode, setOpenNode] = useState(false) const [openLink, setOpenLink] = useState(false) const { suggestionItems, slashCommand } = useSuggestionItems() @@ -134,13 +134,13 @@ const Editor: React.FC = ({ initialValue, onChange, onHtml }) => { const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => { const json = editor.getJSON() - console.log("editor", editor) + // window.localStorage.setItem("html-content", editor.getHTML()) // window.localStorage.setItem("novel-content", JSON.stringify(json)) // window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown()) onChange(json) onHtml(editor.getHTML()) - setSaveStatus("Saved") + // setSaveStatus("Saved") }, 500) // useEffect(() => { @@ -149,7 +149,6 @@ const Editor: React.FC = ({ initialValue, onChange, onHtml }) => { // else setInitialContent(defaultEditorContent) // }, []) - // if (!initialContent) return null return ( = ({ initialValue, onChange, onHtml }) => { }} onUpdate={({ editor }) => { debouncedUpdates(editor) - setSaveStatus("Unsaved") + // setSaveStatus("Unsaved") }} slotAfter={} > diff --git a/packages/app/src/components/commons/Editor/Editor.tsx b/packages/app/src/components/commons/Editor/Editor.tsx deleted file mode 100644 index 5d304688..00000000 --- a/packages/app/src/components/commons/Editor/Editor.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import React, { useEffect, useRef, useState } from "react" -import { - EditorState, - ContentBlock, - KeyBindingUtil, - CompositeDecorator, - ContentState, - DraftInlineStyleType, - RawDraftEntity, - DraftEntityMutability, - Editor as DraftEditor, - AtomicBlockUtils, -} from "draft-js" -import "draft-js/dist/Draft.css" -import { Box } from "@mui/material" -import EditorBlockItem from "./EditorBlock" -import EditorHr from "./EditorComponents/EditorHr" -import { convertToHTML, IConvertToHTMLConfig, convertFromHTML, IConvertFromHTMLConfig } from "draft-convert" -import { useArticleContext } from "../../../services/publications/contexts" -import EditorInlineText from "./EditorInlineText" -import { useNavigate } from "react-router-dom" -import useLinkDecorator from "./hooks/useLinkDecorator" -import useHandleKeyCommand from "./hooks/useHandleKeyCommand" -import useKeyBindingFn from "./hooks/useKeyBindingFn" -import useHandleReturn from "./hooks/useHandleReturn" -import useToggleBlockType from "./hooks/useToggleBlockType" -import useToggleInlineStyle from "./hooks/useToggleInlineStyle" -import useAddRow from "./hooks/useAddRow" -import useDeleteRow from "./hooks/useDeleteRow" -import useHandleSlashCommand from "./hooks/useSlashCommand" -import EditorImagePicker from "./EditorComponents/EditorImagePicker" -import EditorLink from "./EditorComponents/EditorLink" -import EditorShowImage from "./EditorComponents/EditoShowImage" -import { palette, typography } from "../../../theme" -import useHandlePastedText from "./hooks/useHandlePasteText" - - -const { hasCommandModifier } = KeyBindingUtil -type Config = IConvertToHTMLConfig - -const Editor: React.FC = () => { - const { - setArticleEditorState, - setStoreArticleContent, - storeArticleContent, - articleEditorState, - draftArticlePath, - contentImageFiles, - setContentImageFiles, - setLinkComponentUrl, - } = useArticleContext() - const editorContainer = useRef(null) - const navigate = useNavigate() - const linkDecorator = useLinkDecorator() - const decorators = new CompositeDecorator(linkDecorator) - const handleInitialValue = () => { - if (articleEditorState) { - const optionsFromHTML: IConvertFromHTMLConfig> = { - htmlToEntity: ( - nodeName: string, - node: HTMLElement, - createEntity: (type: string, mutability: DraftEntityMutability, data: object) => string, - ) => { - if (nodeName === "img" && contentImageFiles) { - const element = node as HTMLImageElement - const file = contentImageFiles.find((file) => file.lastModified === parseInt(element.alt)) - return createEntity("IMAGE", "IMMUTABLE", { src: element.src, file }) - } - if (nodeName === "img" && !contentImageFiles) { - const element = node as HTMLImageElement - return createEntity("IMAGE", "IMMUTABLE", { src: element.src, file: undefined }) - } - if (nodeName === "hr") { - return createEntity("HR", "IMMUTABLE", {}) - } - if (nodeName === "a") { - return createEntity("LINK", "MUTABLE", { url: (node as HTMLAnchorElement).href }) - } - }, - htmlToBlock: ( - nodeName: string, - node: HTMLElement, - ): string | false | { type: string; data: object } | undefined => { - if (nodeName === "hr") { - return { - type: "atomic", - data: {}, - } - } - if (nodeName === "img" && contentImageFiles) { - const element = node as HTMLImageElement - const file = contentImageFiles.find((file) => file.lastModified === parseInt(element.alt)) - return { - type: "atomic", - data: { - src: element.src, - file, - }, - } - } - if (nodeName === "img" && !contentImageFiles) { - const element = node as HTMLImageElement - return { - type: "atomic", - data: { - src: element.src, - file: undefined, - }, - } - } - return undefined - }, - htmlToStyle: (nodeName: string, node: HTMLElement, currentStyle: Set): Set => { - if (nodeName === "hr") { - return currentStyle.add("HR") - } - return currentStyle - }, - } - const contentState = convertFromHTML(optionsFromHTML)(articleEditorState) - - return EditorState.createWithContent(contentState, decorators) - } - return EditorState.createEmpty(decorators) - } - const [editorState, setEditorState] = useState(handleInitialValue) - const [pendingEditorState, setPendingEditorState] = useState(null) - const editor = useRef(null) - const [inlineEditorOffset, setInlineEditorOffset] = useState({ left: 0, top: 0 }) - const { addRow } = useAddRow(editorState, setEditorState) - const { deleteRow } = useDeleteRow(editorState, setEditorState) - const { showInlinePopup, toggleInlineStyle, setShowInlinePopup } = useToggleInlineStyle(editorState, setEditorState) - const { handleSlashCommand } = useHandleSlashCommand() - const handleKeyCommand = useHandleKeyCommand(editorState, setEditorState) - const keyBindingFn = useKeyBindingFn() - const handleReturn = useHandleReturn(editorState, setEditorState, hasCommandModifier) - const { toggleBlockType, showImagePicker, changeImagePickerState } = useToggleBlockType(editorState, setEditorState) - - useEffect(() => { - editor.current?.focus() - }, []) - - useEffect(() => { - if (!showInlinePopup) { - setLinkComponentUrl(undefined) - } - }, [setLinkComponentUrl, showInlinePopup]) - - useEffect(() => { - if (storeArticleContent) { - const optionsToHTML: Partial = { - styleToHTML: (style) => { - if (style === "STRIKETHROUGH") { - return - } - }, - entityToHTML: (entity, originalText) => { - if (entity.type === "HR") { - return `
` - } - if (entity.type === "LINK") { - return {originalText} - } - if (entity.type === "IMAGE") { - const { file, src } = entity.data - let uri - if (src.includes("https")) { - uri = src - } - if (file) { - uri = URL.createObjectURL(file) - } - return `${file?.lastModified}` - } - return originalText - }, - } - const html = convertToHTML(optionsToHTML)(editorState.getCurrentContent() as ContentState) - setArticleEditorState(html) - setStoreArticleContent(false) - } - if (draftArticlePath) { - navigate(draftArticlePath) - } - }, [storeArticleContent, draftArticlePath, editorState, navigate, setStoreArticleContent, setArticleEditorState]) - - useEffect(() => { - if (pendingEditorState) { - setPendingEditorState(null) - } - }, [pendingEditorState]) - - /** Method to detect selection **/ - useEffect(() => { - const currentSelection = editorState.getSelection() - const start = currentSelection.getStartOffset() - const end = currentSelection.getEndOffset() - - const editorRoot = editor.current - const anchorKey = currentSelection.getAnchorKey() - - if (editorRoot) { - const selectedBlockNodeParent = document.querySelector(`[data-offset-key="${anchorKey}-0-0"]`) - const AVERAGE_CHAR_WIDTH = 7 - - if (selectedBlockNodeParent) { - const selectionWidth = (end - start) * AVERAGE_CHAR_WIDTH - const rect = selectedBlockNodeParent.getBoundingClientRect() - const posX = rect.left + start * AVERAGE_CHAR_WIDTH + selectionWidth / 2 - const posY = rect.top - 64 - - setInlineEditorOffset({ left: posX, top: posY }) - } - } - - if (start !== end) { - setShowInlinePopup(true) - } else { - setShowInlinePopup(false) - } - }, [editorState, setShowInlinePopup]) - - useEffect(() => { - scrollToBottom() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editorState]) - - // go to the end of the text editor - const scrollToBottom = () => { - const currentSelection = editorState.getSelection() - const anchorKey = currentSelection.getAnchorKey() - const currentContent = editorState.getCurrentContent() - const currentBlock = currentContent.getBlockForKey(anchorKey) - - //check if the current blocks is the last - if (currentBlock === currentContent.getLastBlock()) { - editorContainer.current?.scrollIntoView({ behavior: "smooth", block: "end" }) - } - } - const handleState = (editorState: EditorState) => { - const finalEditorState = handleSlashCommand(editorState) - setEditorState(finalEditorState) - } - - const onImageSelected = (uri: any, file: any) => { - const currentFiles = contentImageFiles ? [...contentImageFiles] : [] - setContentImageFiles([...currentFiles, file]) - const contentState = editorState.getCurrentContent() - const contentStateWithEntity = contentState.createEntity("IMAGE", "IMMUTABLE", { src: uri, file: file }) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - const newEditorState = EditorState.set(editorState, { currentContent: contentStateWithEntity }) - - // move the cursor to the end - const lastBlock = contentState.getLastBlock() - const lengthOfLastBlock = lastBlock.getLength() - const selection = editorState.getSelection() - const newSelection = selection.merge({ - anchorOffset: lengthOfLastBlock, - focusOffset: lengthOfLastBlock, - anchorKey: lastBlock.getKey(), - focusKey: lastBlock.getKey(), - }) - const newStateWithCursorAtEnd = EditorState.forceSelection(newEditorState, newSelection) - const editorStateWithImage = AtomicBlockUtils.insertAtomicBlock(newStateWithCursorAtEnd, entityKey, " ") - setEditorState(editorStateWithImage) - } - - const blockRendererFn = (block: ContentBlock) => { - const focusedBlockKey = editorState.getSelection().getStartKey() - const isFocused = block.getKey() === focusedBlockKey - if (block.getType() === "atomic") { - const contentState = editorState.getCurrentContent() - const entityKey = block.getEntityAt(0) - if (entityKey) { - const entity = contentState.getEntity(entityKey) - if (entity.getType() === "HR") { - return { - component: EditorHr, - editable: false, - } - } - if (entity.getType() === "IMAGE") { - return { - component: EditorShowImage, - editable: false, - props: { - file: entity.getData().file, - src: entity.getData().src, - editorState, - }, - } - } - } - } - return { - component: EditorBlockItem, - editable: true, - props: { toggleBlockType, editorState, onAdd: addRow, onDelete: deleteRow, isFocused }, - } - } - - const getActiveInlineStyles = () => { - const selection = editorState.getSelection() - const anchorKey = selection.getAnchorKey() - const currentContent = editorState.getCurrentContent() - const currentContentBlock = currentContent.getBlockForKey(anchorKey) - const start = selection.getStartOffset() - const end = selection.getEndOffset() - - let styles: { style: string; data?: any }[] = [] - - for (let i = start; i < end; i++) { - const newStyles = currentContentBlock.getInlineStyleAt(i).toArray() - styles = [...styles, ...newStyles.map((style) => ({ style }))] - - const entityKey = currentContentBlock.getEntityAt(i) - if (entityKey) { - const entity = currentContent.getEntity(entityKey) - if (entity.getType() === "LINK") { - styles.push({ style: "LINK", data: entity.getData() }) - } - } - } - - // remove duplicated - styles = Array.from(new Set(styles.map((s) => JSON.stringify(s)))).map((s) => JSON.parse(s)) - - return styles // return selection styles - } - - const styleMap = { - CODE: { - backgroundColor: palette.grays[800], - borderRadius: 4, - color: palette.whites[1000], - fontFamily: typography.fontFamilies.monospace, - marginBottom: "1rem", - overflow: "auto", - padding: "0.2rem", - fontSize: 13, - }, - } - - return ( - - - toggleInlineStyle(slug)} - getActiveInlineStyles={getActiveInlineStyles} - /> - {showImagePicker && ( - { - onImageSelected(uri, file) - changeImagePickerState(false) - }} - /> - )} - - ) -} - -export default Editor diff --git a/packages/app/src/components/commons/Editor/EditorBlock.tsx b/packages/app/src/components/commons/Editor/EditorBlock.tsx deleted file mode 100644 index 68c021cc..00000000 --- a/packages/app/src/components/commons/Editor/EditorBlock.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { ContentBlock, EditorBlock, EditorState, SelectionState } from "draft-js" -import EditorRichText from "./EditorRichText" -import { Box, Typography } from "@mui/material" -import { useArticleContext } from "../../../services/publications/contexts" -import React from "react" -import { palette } from "../../../theme" - -interface EditorBlockItemProps { - block: ContentBlock - blockProps: { - editorState: EditorState - onDelete: () => void - onAdd: () => void - toggleBlockType: (blockType: string) => void - isFocused: boolean - } - selection: SelectionState -} - -const EditorBlockItem: React.FC = (props) => { - const { showBlockTypePopup } = useArticleContext() - const selectedBlockKey = props.selection.getAnchorKey() - const isBlockFocused = props.block.getKey() === selectedBlockKey - const isEmpty = props.block.getText().length === 0 - const isFocused = props.blockProps.isFocused - const type = props.block.getType() - - const handleTop = (): number => { - switch (type) { - case "header-one": - return 12 - case "header-two": - return 2 - case "header-three": - return 10 - case "header-four": - return 6 - case "header-five": - return 4 - case "header-six": - return 2 - default: - return 0 - } - } - return ( - - {isBlockFocused && isEmpty && isFocused && ( - - Type '/' for commands... - - )} - {isBlockFocused && ( - - props.blockProps.onDelete()} - onAdd={() => props.blockProps.onAdd()} - onRichTextSelected={(value) => props.blockProps.toggleBlockType(value)} - /> - - )} - - - ) -} - -export default EditorBlockItem diff --git a/packages/app/src/components/commons/Editor/EditorInlineText.tsx b/packages/app/src/components/commons/Editor/EditorInlineText.tsx deleted file mode 100644 index 6d3a4185..00000000 --- a/packages/app/src/components/commons/Editor/EditorInlineText.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useEffect, useState, useRef } from "react" -// import { useOnClickOutside } from "../../hooks/useOnClickOutside" -import { Box, FormHelperText, Portal, Stack, SxProps, TextField, Theme } from "@mui/material" -import { ReactComponent as BoldIcon } from "../../../assets/images/boldIcon.svg" -import { ReactComponent as ItalicIcon } from "../../../assets/images/italicIcon.svg" -import { ReactComponent as UnderlineIcon } from "../../../assets/images/underlineIcon.svg" -import { ReactComponent as StrikethroughIcon } from "../../../assets/images/strikethroughIcon.svg" -import { ReactComponent as CodeIcon } from "../../../assets/images/codeIcon.svg" -import { ReactComponent as LinkIcon } from "../../../assets/images/linkIcon.svg" -import { palette } from "../../../theme" -import { useArticleContext } from "../../../services/publications/contexts" - -const inlineStyleOptions = [ - { - slug: "BOLD", - tag: "b", - icon: , - }, - { - slug: "ITALIC", - tag: "i", - icon: , - }, - { - slug: "UNDERLINE", - tag: "span", - icon: , - }, - { - slug: "STRIKETHROUGH", - tag: "span", - icon: , - }, - { - slug: "CODE", - tag: "code", - icon: , - }, - { - slug: "LINK", - tag: "a", - icon: , - }, -] - -type InlineStyleOptions = { - slug: string - tag: string - icon: React.ReactNode - sx?: SxProps -} - -type InlineRichTextProps = { - showCommand: boolean - inlineEditorOffset?: any - onClick: (slug: string) => void - getActiveInlineStyles: () => { style: string; data?: any }[] -} - -const EditorInlineText: React.FC = ({ - inlineEditorOffset, - showCommand, - onClick, - getActiveInlineStyles, -}) => { - const containerRef = useRef Element | null) | null>(null) - const ref = useRef(null) - const { linkComponentUrl, setLinkComponentUrl } = useArticleContext() - const [show, setShow] = useState(false) - const [top, setTop] = useState() - const [left, setLeft] = useState() - const [showUrlInput, setShowUrlInput] = useState(false) - const [showInvalidUrl, setShowInvalidUrl] = useState(false) - const validUrl = linkComponentUrl && linkComponentUrl.includes("https://") - const activeStyles: { style: string; data?: any }[] = getActiveInlineStyles() - useEffect(() => { - setShow(showCommand) - }, [showCommand]) - - useEffect(() => { - if (inlineEditorOffset) { - setTop(inlineEditorOffset.top) - setLeft(Math.max(inlineEditorOffset.left, 172)) - } - }, [inlineEditorOffset]) - - useEffect(() => { - if (activeStyles.length && !linkComponentUrl && linkComponentUrl !== "") { - let linkData - const linkStyle = activeStyles.find((styleObj) => styleObj.style === "LINK") - - if (linkStyle) { - linkData = linkStyle.data.url - } - setLinkComponentUrl(linkData) - } - }, [activeStyles, showUrlInput, linkComponentUrl, setLinkComponentUrl]) - - const handleStyles = (slug: string): SxProps => { - const isActiveSlug = activeStyles.some((styleObj) => styleObj.style === slug) - - let styles = { - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - p: 1, - bgcolor: palette.grays[50], - borderRadius: 1, - "&:hover": { - bgcolor: palette.grays[100], - }, - "&.is-active": { - bgcolor: palette.grays[400], - }, - } - - if (isActiveSlug) { - styles = { - ...styles, - cursor: "default", - bgcolor: palette.grays[400], - "&:hover": { - bgcolor: palette.grays[400], - }, - "&.is-active": { - bgcolor: palette.grays[400], - }, - } - } - - return styles - } - - const handleClick = (slug: string) => { - if (slug === "LINK") { - if (validUrl) { - onClick("LINK") - setShowInvalidUrl(false) - return - } - setShowUrlInput(!showUrlInput) - return - } - onClick(slug) - } - - const handleLink = () => { - if (validUrl) { - onClick("LINK") - setShowInvalidUrl(false) - return - } - setShowInvalidUrl(true) - } - - return ( - - {show && ( - - {showUrlInput && ( - - setLinkComponentUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleLink()} - InputProps={{ - startAdornment: , - }} - /> - {showInvalidUrl && Invalid url} - - )} - - - {inlineStyleOptions.map(({ slug, icon }: InlineStyleOptions, index) => { - return ( - handleClick(slug)} sx={handleStyles(slug)}> - {icon} - - ) - })} - - - )} - - ) -} - -export default EditorInlineText diff --git a/packages/app/src/components/commons/Editor/EditorRichText.tsx b/packages/app/src/components/commons/Editor/EditorRichText.tsx deleted file mode 100644 index a4c9212d..00000000 --- a/packages/app/src/components/commons/Editor/EditorRichText.tsx +++ /dev/null @@ -1,458 +0,0 @@ -import styled from "@emotion/styled" -import { Box, Divider, Grid, Portal, Stack, Tooltip, Typography } from "@mui/material" -import React, { useEffect, useLayoutEffect, useState, useRef, createRef } from "react" - -import AddIcon from "@mui/icons-material/Add" -import { ReactComponent as ParagraphIcon } from "../../../assets/images/paragraphIcon.svg" -import { ReactComponent as ImageIcon } from "../../../assets/images/imageIcon.svg" -import { ReactComponent as OrderedIcon } from "../../../assets/images/orderedIcon.svg" -import { ReactComponent as UnorderedIcon } from "../../../assets/images/unorderedIcon.svg" -import { ReactComponent as CodeIcon } from "../../../assets/images/codeIcon.svg" -import { ReactComponent as QuoteIcon } from "../../../assets/images/quoteIcon.svg" -import { ReactComponent as DividerIcon } from "../../../assets/images/dividerIcon.svg" -import { ReactComponent as TrashIcon } from "../../../assets/images/trashIcon.svg" -import { DragIndicator } from "@mui/icons-material" -import { palette, typography } from "../../../theme" -import { useOnClickOutside } from "../../../hooks/useOnClickOutside" -import { useArticleContext } from "../../../services/publications/contexts" -import useLocalStorage from "../../../hooks/useLocalStorage" -import { Pinning, PinningService } from "../../../models/pinning" - -const RichTextButton = styled(Box)({ - position: "relative", - width: 24, - height: 24, - display: "flex", - justifyContent: "center", - alignItems: "center", - borderRadius: 4, - cursor: "pointer", - "&:hover": { - background: palette.grays[100], - }, -}) - -const RichTextContainer = styled(Box)({ - position: "absolute", - maxWidth: 140, - background: palette.whites[1000], - borderRadius: 8, - boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.05)", - padding: 8, - boxSizing: "border-box", -}) - -const RichTextItemContainer = styled(Box)({ - width: 40, - height: 40, - cursor: "pointer", - background: palette.grays[50], - borderRadius: 4, - display: "flex", - justifyContent: "center", - alignItems: "center", - "&:hover": { - background: palette.grays[100], - }, -}) - -export enum RICH_TEXT_ELEMENTS { - H1 = "header-one", - H2 = "header-two", - H3 = "header-three", - H4 = "header-four", - H5 = "header-five", - H6 = "header-six", - PARAGRAPH = "unstyled", - IMAGE = "image-picker", - ORDERED = "ordered-list-item", - UNORDERED = "unordered-list-item", - CODE = "code-block", - QUOTE = "blockquote", - DIVIDER = "hr", -} - -type RichTextItemProps = { - label?: string - color?: string - icon: React.ReactNode - selected?: boolean - disabled?: boolean -} - -type RichTextProps = { - showCommand: boolean - onRichTextSelected?: (value: string) => void - onDelete: () => void - onAdd: () => void -} - -const HEADER_OPTIONS = [ - { - value: RICH_TEXT_ELEMENTS.H1, - icon: ( - - H1 - - ), - }, - { - value: RICH_TEXT_ELEMENTS.H2, - icon: ( - - H2 - - ), - }, - { - value: RICH_TEXT_ELEMENTS.H3, - icon: ( - - H3 - - ), - }, - { - value: RICH_TEXT_ELEMENTS.H4, - icon: ( - - H4 - - ), - }, - { - value: RICH_TEXT_ELEMENTS.H5, - icon: ( - - H5 - - ), - }, - { - value: RICH_TEXT_ELEMENTS.H6, - icon: ( - - H6 - - ), - }, -] - -const OPTIONS = [ - { - value: RICH_TEXT_ELEMENTS.PARAGRAPH, - label: "Paragraph", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.IMAGE, - label: "Image", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.ORDERED, - label: "Ordered List", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.UNORDERED, - label: "Unordered List", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.CODE, - label: "Code Snippet", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.QUOTE, - label: "Quote", - icon: , - }, - { - value: RICH_TEXT_ELEMENTS.DIVIDER, - label: "Divider Line", - icon: , - }, -] - -const DragTooltipContent = () => { - return ( - <> - - Click to Edit - - - ) -} - -const RichTextItem: React.FC = ({ label, icon, color, selected, disabled }) => { - return ( - - - {icon} - - {label && ( - - - {label} - - - )} - - ) -} - -const EditorRichText: React.FC = ({ onRichTextSelected, showCommand, onDelete, onAdd }) => { - const { setShowBlockTypePopup } = useArticleContext() - const [pinning] = useLocalStorage("pinning", undefined) - const isDirectlyOnChain = pinning && pinning.service === PinningService.NONE - const containerRef = useRef Element | null) | null>(null) - const richTextRef = useRef(null) - const ref = useRef(null) - const headerOptionRefs = HEADER_OPTIONS.map(() => createRef()) - const optionRefs = OPTIONS.map(() => createRef()) - const [selectedIndex, setSelectedIndex] = useState(0) - - const [show, setShow] = useState(false) - const [top, setTop] = useState() - const [topOffset, setTopOffset] = useState(32) - const [left, setLeft] = useState() - - useOnClickOutside(ref, () => { - if (show) { - setShow(!show) - setShowBlockTypePopup(false) - } - }) - - useEffect(() => { - setShow(showCommand) - }, [showCommand]) - - useEffect(() => { - if (richTextRef.current) { - const result = richTextRef.current.getBoundingClientRect() - const menuHeight = 311 // richTextRef.current.clientHeight - - if (menuHeight + 32 + result.top > window.innerHeight) { - setTopOffset(-menuHeight - 16) - } else { - setTopOffset(32) - } - setTop(result.top + topOffset) - setLeft(Math.max(result.left - 115, 64)) - } - }, [topOffset]) - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const richTextOption = HEADER_OPTIONS.concat(OPTIONS) - switch (event.key) { - case "ArrowUp": - event.preventDefault() - setSelectedIndex((prevIndex) => Math.max(prevIndex - 1, 0)) - break - - case "ArrowDown": - event.preventDefault() - setSelectedIndex((prevIndex) => Math.min(prevIndex + 1, richTextOption.length - 1)) - break - - case "Enter": - event.preventDefault() - setSelectedIndex((currentIndex) => { - const selectedOption = richTextOption[currentIndex] - if (selectedOption) { - handleSelection(selectedOption.value) - } - return currentIndex - }) - break - - default: - break - } - } - - if (show) { - window.addEventListener("keydown", handleKeyDown) - } else { - window.removeEventListener("keydown", handleKeyDown) - } - - return () => { - window.removeEventListener("keydown", handleKeyDown) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [show]) - - useEffect(() => { - const selectedOptionRef = selectedIndex < 6 ? headerOptionRefs[selectedIndex] : optionRefs[selectedIndex - 6] - - setTimeout(() => { - if (selectedOptionRef && selectedOptionRef.current && richTextRef.current) { - const selectedOptionRect = selectedOptionRef.current.getBoundingClientRect() - const optionsContainerRect = richTextRef.current.getBoundingClientRect() - - if ( - selectedOptionRect.bottom > optionsContainerRect.bottom || - selectedOptionRect.top < optionsContainerRect.top - ) { - richTextRef.current.scrollTop = selectedOptionRef.current.offsetTop - 10 - } - } - }, 0) - }, [headerOptionRefs, optionRefs, selectedIndex]) - - useLayoutEffect(() => { - function updatePosition() { - if (richTextRef.current) { - const result = richTextRef.current.getBoundingClientRect() - const menuHeight = 311 // richTextRef.current.clientHeight - - if (menuHeight + 32 + result.top > window.innerHeight) { - setTopOffset(-menuHeight - 16) - } else { - setTopOffset(32) - } - setTop(result.top + topOffset) - setLeft(Math.max(result.left - 115, 64)) - } - } - window.addEventListener("resize", updatePosition) - updatePosition() - return () => window.removeEventListener("resize", updatePosition) - }, [topOffset]) - - const handleSelection = (value: RICH_TEXT_ELEMENTS) => { - if (value === RICH_TEXT_ELEMENTS.IMAGE && isDirectlyOnChain) { - return - } - if (onRichTextSelected) { - onRichTextSelected(value) - setShow(false) - setShowBlockTypePopup(false) - } - } - const handleDelete = () => { - setShow(false) - setShowBlockTypePopup(false) - onDelete() - } - - useEffect(() => { - if (show && headerOptionRefs[0].current) { - headerOptionRefs[0].current.focus() - } - }, [headerOptionRefs, show]) - - return ( - <> - - - Click to add a new block - - } - > - - - - - }> - setShow(!show)}> - - - - - - - {show && ( - - - - - - - - Heading - - - - - {HEADER_OPTIONS.map(({ icon, value }, index) => ( - -
value && handleSelection(value)} tabIndex={0}> - -
-
- ))} -
-
-
-
- - {OPTIONS.map(({ label, icon, value }, index) => ( - -
value && handleSelection(value)} tabIndex={0}> - -
-
- ))} -
- - - - } /> - -
-
- )} -
- - ) -} - -export default EditorRichText diff --git a/packages/app/src/components/commons/Editor/EditorSlashCommand.tsx b/packages/app/src/components/commons/Editor/EditorSlashCommand.tsx index b1042c91..db7dae87 100644 --- a/packages/app/src/components/commons/Editor/EditorSlashCommand.tsx +++ b/packages/app/src/components/commons/Editor/EditorSlashCommand.tsx @@ -1,4 +1,3 @@ -import { useCallback } from "react" import { createSuggestionItems } from "novel/extensions" import { Command, renderItems } from "novel/extensions" import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft" @@ -17,10 +16,8 @@ const useSuggestionItems = () => { const { encodeIpfsHash } = useIPFSContext() const onUpload = async (file: File) => { - console.log("entre") return encodeIpfsHash(file).then((hash) => { const imageUrl = `https://ipfs.io/ipfs/${hash}` - console.log("imageUrl", imageUrl) return imageUrl }) } diff --git a/packages/app/src/components/commons/Editor/default-value.ts b/packages/app/src/components/commons/Editor/default-value.ts index d78d9732..35adf0b9 100644 --- a/packages/app/src/components/commons/Editor/default-value.ts +++ b/packages/app/src/components/commons/Editor/default-value.ts @@ -1,4 +1,6 @@ -export const defaultValue = { +import { JSONContent } from "novel"; + +export const defaultValue: JSONContent = { type: "doc", - content: [], + content: [{ type: "paragraph" }], } diff --git a/packages/app/src/components/commons/Editor/generative/GenerativeMenuSwitch.tsx b/packages/app/src/components/commons/Editor/generative/GenerativeMenuSwitch.tsx index cba3c4c7..e8f6d4c7 100644 --- a/packages/app/src/components/commons/Editor/generative/GenerativeMenuSwitch.tsx +++ b/packages/app/src/components/commons/Editor/generative/GenerativeMenuSwitch.tsx @@ -4,7 +4,7 @@ import { Fragment, useEffect, type ReactNode } from "react" import {} from "novel/plugins" import { removeAIHighlight } from "novel/extensions" import { makeStyles } from "@mui/styles" -import AISelector from "./AiSelector" +// import AISelector from "./AiSelector" import { Button } from "@mui/material" interface GenerativeMenuSwitchProps { @@ -44,7 +44,7 @@ const GenerativeMenuSwitch = ({ children, open, onOpenChange }: GenerativeMenuSw }, }} > - {open && } + {/* {open && } */} {!open && ( @@ -397,9 +122,9 @@ const ArticleHeader: React.FC = ({ publication, type }) => { variant="contained" onClick={handlePublishAction} sx={{ fontSize: 14, py: "2px", minWidth: "unset" }} - disabled={createLoading || ipfsLoading} + disabled={createLoading || updateLoading || ipfsLoading} > - {createLoading && } + {(createLoading || updateLoading) && } Publish diff --git a/packages/app/src/components/layout/PublicationHeader.tsx b/packages/app/src/components/layout/PublicationHeader.tsx index 0d10a0d7..3f39096f 100644 --- a/packages/app/src/components/layout/PublicationHeader.tsx +++ b/packages/app/src/components/layout/PublicationHeader.tsx @@ -1,21 +1,22 @@ -import React, { useEffect, useMemo, useRef, useState } from "react" +import React, { useEffect, useRef, useState } from "react" import { Box, Button, Container, Grid, styled, Typography } from "@mui/material" -import { WalletBadge } from "../commons/WalletBadge" -import { Publication } from "../../models/publication" +import { WalletBadge } from "@/components/commons/WalletBadge" +import { Publication } from "@/models/publication" import AddIcon from "@mui/icons-material/Add" -import theme, { palette, typography } from "../../theme" +import theme, { palette, typography } from "@/theme" import { useLocation, useNavigate, useParams } from "react-router-dom" -import usePublication from "../../services/publications/hooks/usePublication" -import { haveActionPermission } from "../../utils/permission" -import { INITIAL_ARTICLE_VALUE, useArticleContext, usePublicationContext } from "../../services/publications/contexts" -import { UserOptions } from "../commons/UserOptions" -// import { useOnClickOutside } from "../../hooks/useOnClickOutside" +import usePublication from "@/services/publications/hooks/usePublication" +import { haveActionPermission } from "@/utils/permission" +import { INITIAL_ARTICLE_VALUE, useArticleContext } from "@/services/publications/contexts" +import { UserOptions } from "@/components/commons/UserOptions" +// import { useOnClickOutside } from "@/hooks/useOnClickOutside" import { Edit } from "@mui/icons-material" -import isIPFS from "is-ipfs" -import Avatar from "../commons/Avatar" -import { useIpfs } from "../../hooks/useIpfs" -import { processArticleContent } from "../../utils/modifyHTML" +// import isIPFS from "is-ipfs" +// import { useIpfs } from "@/hooks/useIpfs" +import Avatar from "@/components/commons/Avatar" +// import { processArticleContent } from "@/utils/modifyHTML" import { useWeb3Modal, useWeb3ModalAccount } from "@web3modal/ethers5/react" +import { addUrlToImageHashes } from "@/services/publications/utils/article-method" type Props = { articleId?: string @@ -32,13 +33,12 @@ const ItemContainer = styled(Grid)({ margin: "15px 0px", }, }) -const PublicationHeader: React.FC = ({ articleId, publication, showCreatePost, showEditButton }) => { - const ipfs = useIpfs() +const PublicationHeader: React.FC = ({ publication, showCreatePost, showEditButton }) => { + // const ipfs = useIpfs() const { publicationSlug } = useParams<{ publicationSlug: string }>() const { address, isConnected } = useWeb3ModalAccount() const navigate = useNavigate() const location = useLocation() - const { savePublication } = usePublicationContext() const { article, setCurrentPath, @@ -47,12 +47,13 @@ const PublicationHeader: React.FC = ({ articleId, publication, showCreate setMarkdownArticle, setDraftArticleThumbnail, setArticleEditorState, + articleFormMethods, } = useArticleContext() const { open } = useWeb3Modal() - const { refetch, chainId: publicationChainId } = usePublication(publicationSlug || "") + const { refetch } = usePublication(publicationSlug || "") const [show, setShow] = useState(false) const permissions = publication && publication.permissions - const isValidHash = useMemo(() => article && isIPFS.multihash(article.article), [article]) + // const isValidHash = useMemo(() => article && isIPFS.multihash(article.article), [article]) const ref = useRef() // useOnClickOutside(ref, () => { @@ -61,6 +62,7 @@ const PublicationHeader: React.FC = ({ articleId, publication, showCreate // } // }) + const { setValue } = articleFormMethods useEffect(() => { if (location.pathname) { setCurrentPath(location.pathname) @@ -79,12 +81,21 @@ const PublicationHeader: React.FC = ({ articleId, publication, showCreate const handleEditNavigation = async () => { if (article) { - await processArticleContent(article, ipfs, isValidHash ?? false).then(({ img, content, modifiedHTMLString }) => { - saveDraftArticle({ ...article, title: article.title, image: img }) - savePublication(article.publication) - setArticleEditorState(modifiedHTMLString ?? content ?? undefined) - navigate(`/${publicationSlug}/${articleId}/edit`) - }) + setValue("id", article.id) + setValue("title", article.title) + setValue("article", addUrlToImageHashes(article.article)) + setValue("description", article.description ?? undefined) + setValue("lastUpdated", article.lastUpdated ?? undefined) + setValue( + "tags", + article.tags + ? article.tags.map((tag) => { + return { label: tag, value: tag } + }) + : undefined, + ) + setValue("image", article.image) + navigate(`/${article.publication?.id}/edit`) } } diff --git a/packages/app/src/components/views/publication/ArticleView.tsx b/packages/app/src/components/views/publication/ArticleView.tsx index 6d141b0c..bad170d1 100644 --- a/packages/app/src/components/views/publication/ArticleView.tsx +++ b/packages/app/src/components/views/publication/ArticleView.tsx @@ -1,61 +1,47 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { Chip, CircularProgress, Divider, Grid, Typography } from "@mui/material" import moment from "moment" -import React, { useCallback, useEffect, useMemo, useState } from "react" +import React, { useEffect, useMemo, useState } from "react" import { Helmet } from "react-helmet" import { useParams } from "react-router-dom" import { useArticleContext } from "@/services/publications/contexts" import useArticle from "@/services/publications/hooks/useArticle" import { palette, typography } from "@/theme" -import { Markdown } from "@/components/commons/Markdown" import { ViewContainer } from "@/components/commons/ViewContainer" import PublicationPage from "@/components/layout/PublicationPage" -import isIPFS from "is-ipfs" +// import isIPFS from "is-ipfs" import { useDynamicFavIcon } from "@/hooks/useDynamicFavIco" import usePublication from "@/services/publications/hooks/usePublication" -import { convertToMarkdown } from "@/utils/string-handler" +import { HtmlRenderer } from "@/components/commons/HtmlRender" +import { addUrlToImageHashes } from "@/services/publications/utils/article-method" -interface ArticleViewProps { - -} +interface ArticleViewProps {} //Provisional solution to detect older articles and check the dif between markdown and html articles -const VALIDATION_DATE = "2023-02-02T00:00:00Z" -export const ArticleView: React.FC = ( ) => { +// const VALIDATION_DATE = "2023-02-02T00:00:00Z" +export const ArticleView: React.FC = () => { const { publicationSlug } = useParams<{ publicationSlug: string }>() const { articleId } = useParams<{ articleId: string }>() const { article, saveArticle, - markdownArticle, - setMarkdownArticle, loading, - getIpfsData, - setArticleEditorState, - saveDraftArticle, - draftArticle, } = useArticleContext() const { data, executeQuery, imageSrc } = useArticle(articleId || "") const publication = usePublication(publicationSlug || "") useDynamicFavIcon(publication?.imageSrc) - const dateCreation = useMemo( - () => article?.postedOn && new Date(parseInt(article.postedOn) * 1000), - [article?.postedOn], - ) + // const dateCreation = useMemo( + // () => article?.postedOn && new Date(parseInt(article.postedOn) * 1000), + // [article?.postedOn], + // ) const date = useMemo( () => article?.lastUpdated && new Date(parseInt(article.lastUpdated) * 1000), [article?.lastUpdated], ) - const isAfterHtmlImplementation = useMemo(() => moment(dateCreation).isAfter(VALIDATION_DATE), [dateCreation]) - const isValidHash = useMemo(() => article && isIPFS.multihash(article.article), [article?.article]) + // const isAfterHtmlImplementation = useMemo(() => moment(dateCreation).isAfter(VALIDATION_DATE), [dateCreation]) + // const isValidHash = useMemo(() => article && isIPFS.multihash(article.article), [article?.article]) const [articleToShow, setArticleToShow] = useState("") - // useEffect(() => { - // if (publication.chainId != null) { - // updateChainId(publication.chainId) - // } - // }, [publication, updateChainId]) - useEffect(() => { if (!article && articleId) { executeQuery() @@ -68,50 +54,11 @@ export const ArticleView: React.FC = ( ) => { } }, [data, article, saveArticle]) - const fetchArticleContent = useCallback(async () => { - try { - if (isValidHash && article && !markdownArticle) { - await getIpfsData(article.article) - return - } - if (!isValidHash && article) { - if (!isAfterHtmlImplementation) { - return setArticleToShow(article.article) - } - const markdownContent = convertToMarkdown(article.article) - setArticleToShow(markdownContent) - } - } catch (error: any) { - if (error.message.includes("504")) { - // Handle specific 504 error - console.error("There was an issue fetching the hash content. Please try again later.") - } else { - // Handle other general errors - console.error("An error occurred: ", error) - } - } - }, [isValidHash, article, markdownArticle, getIpfsData, isAfterHtmlImplementation]) - - useEffect(() => { - if (article && !draftArticle?.title) { - saveDraftArticle(article) - fetchArticleContent() - } - }, [article, fetchArticleContent]) - - useEffect(() => { - if (markdownArticle) { - setArticleEditorState(markdownArticle) - const markdownContent = convertToMarkdown(markdownArticle) - setArticleToShow(markdownContent) - } - }, [markdownArticle]) - useEffect(() => { - return () => { - setMarkdownArticle(undefined) + if (article && !articleToShow) { + setArticleToShow(addUrlToImageHashes(article.article)) } - }, [setMarkdownArticle]) + }, [article]) return ( = ( ) => { ))} )} - - {articleToShow} + + {articleToShow && } + {/* {articleToShow} */} diff --git a/packages/app/src/components/views/publication/CreateArticleView.tsx b/packages/app/src/components/views/publication/CreateArticleView.tsx index 555a200c..09a3c52f 100644 --- a/packages/app/src/components/views/publication/CreateArticleView.tsx +++ b/packages/app/src/components/views/publication/CreateArticleView.tsx @@ -1,14 +1,14 @@ +import React, { useEffect, useState } from "react" +import CreateArticlePage from "@/components/layout/CreateArticlePage" +import Editor from "@/components/commons/Editor/AdvanceEditor" import { Box, Container, FormHelperText, Grid, InputLabel, Stack, TextField, Typography } from "@mui/material" - -import React, { useState } from "react" import { useArticleContext, usePublicationContext } from "@/services/publications/contexts" import { palette } from "@/theme" -import CreateArticlePage from "@/components/layout/CreateArticlePage" - -import Editor from "@/components/commons/Editor/AdvanceEditor" import { defaultValue } from "@/components/commons/Editor/default-value" import { JSONContent } from "novel" import { Controller } from "react-hook-form" +import { generateJSON } from "@tiptap/html" +import useExtensions from "@/components/commons/Editor/extensions" interface CreateArticleViewProps { type: "new" | "edit" @@ -16,22 +16,18 @@ interface CreateArticleViewProps { export const CreateArticleView: React.FC = React.memo(({ type }) => { const { publication } = usePublicationContext() - const { - // draftArticle, - // updateDraftArticle, - // articleTitleError, - // articleContentError, - setArticleHtml, - articleFormMethods, - } = useArticleContext() - const [value, setValue] = useState(defaultValue) - + const { setArticleHtml, articleFormMethods } = useArticleContext() + const [value, setValue] = useState(undefined) + const extensions = useExtensions() const { control, + getValues, setValue: saveArticleValue, formState: { errors }, } = articleFormMethods + const defaultArticleHtml = getValues("article") + const handleEditorChange = (htmlContent: string) => { if (htmlContent === "

") { saveArticleValue("article", "") @@ -44,6 +40,15 @@ export const CreateArticleView: React.FC = React.memo(({ } } + useEffect(() => { + if (!value) { + if (defaultArticleHtml) { + return setValue(generateJSON(defaultArticleHtml, extensions)) + } + setValue(defaultValue) + } + }, [defaultArticleHtml, extensions, value]) + return ( = React.memo(({ - ( - - )} - /> + {value && ( + ( + + )} + /> + )} {errors.article && ( {errors.article.message} diff --git a/packages/app/src/components/views/publication/PreviewArticleView.tsx b/packages/app/src/components/views/publication/PreviewArticleView.tsx index b33a58a8..8ee9f943 100644 --- a/packages/app/src/components/views/publication/PreviewArticleView.tsx +++ b/packages/app/src/components/views/publication/PreviewArticleView.tsx @@ -1,40 +1,42 @@ -import React, { Fragment, useState } from "react" +import React, { Fragment, useEffect, useState } from "react" import { useLocation } from "react-router-dom" import { Box, Chip, Grid, Typography } from "@mui/material" import CreateArticlePage from "@/components/layout/CreateArticlePage" import { useArticleContext, usePublicationContext } from "@/services/publications/contexts" import { ViewContainer } from "@/components/commons/ViewContainer" import { HtmlRenderer } from "@/components/commons/HtmlRender" +import { toBase64 } from "@/utils/string-handler" const PreviewArticleView: React.FC = () => { const location = useLocation() const { publication } = usePublicationContext() - const { draftArticle, draftArticleThumbnail, articleEditorState, articleHtml } = useArticleContext() + const { articleFormMethods } = useArticleContext() const [thumbnailUri, setThumbnailUri] = useState(undefined) - + const { getValues, watch } = articleFormMethods + const title = getValues("title") + const articleHtml = getValues("article") + const tags = watch("tags") + const thumbnail = getValues("image") const isEdit = location.pathname.includes("edit") && "edit" const isNew = location.pathname.includes("new") && "new" - // useEffect(() => { - // if (articleEditorState) { - // setArticleHtml(articleEditorState) - // } - // }, [articleEditorState]) + useEffect(() => { + const transformImg = async () => { + if (typeof thumbnail === "string") { + setThumbnailUri(`https://ipfs.io/ipfs/${thumbnail}`) + } + if (thumbnail) { + const base64Image = await toBase64(thumbnail) + setThumbnailUri(base64Image) + } else { + setThumbnailUri(undefined) + } + } - // useEffect(() => { - // const transformImg = async () => { - // if (draftArticleThumbnail) { - // const content = await toBase64(draftArticleThumbnail) - // setThumbnailUri(content) - // } else { - // setThumbnailUri(undefined) - // } - // } - // transformImg() - // }, [draftArticleThumbnail]) + transformImg() + }, [thumbnail]) - console.log('articleHtml', articleHtml) return ( { {thumbnailUri && } - {draftArticle && ( - - - {draftArticle.title} - - - - {draftArticle.tags && - draftArticle.tags.length > 0 && - draftArticle.tags.map((tag, index) => ( - - - - ))} - - - - {/* {loading && ( - - - Decrypting data from IPFS...please wait a moment - - )} */} - {/* {article} */} - {articleHtml && } + + + + {title} + + + + {tags && + tags.length > 0 && + tags.map((tag: { label: string; value: string } | string, index: number) => ( + + + + ))} - - )} + + + {articleHtml && } + + diff --git a/packages/app/src/components/views/publication/components/ArticleContentSection.tsx b/packages/app/src/components/views/publication/components/ArticleContentSection.tsx deleted file mode 100644 index 19547169..00000000 --- a/packages/app/src/components/views/publication/components/ArticleContentSection.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Box } from "@mui/material" -import React from "react" -import { palette } from "../../../../theme" -import Editor from "../../../commons/Editor/Editor" - -export const ArticleContentSection: React.FC = React.memo(() => { - return ( - - - - - - ) -}) diff --git a/packages/app/src/components/views/publication/components/ArticleItem.tsx b/packages/app/src/components/views/publication/components/ArticleItem.tsx index 5d294050..5b8a6ef8 100644 --- a/packages/app/src/components/views/publication/components/ArticleItem.tsx +++ b/packages/app/src/components/views/publication/components/ArticleItem.tsx @@ -11,14 +11,15 @@ import moment from "moment" import { useArticleContext } from "@/services/publications/contexts" import { useNavigate } from "react-router-dom" import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline" -import usePoster from "@/services/poster/hooks/usePoster" import usePublication from "@/services/publications/hooks/usePublication" import { usePosterContext } from "@/services/poster/context" import useArticle from "@/services/publications/hooks/useArticle" import isIPFS from "is-ipfs" import { useIpfs } from "@/hooks/useIpfs" import { shortTitle } from "@/utils/string-handler" -import { processArticleContent } from "@/utils/modifyHTML" +// import { processArticleContent } from "@/utils/modifyHTML" +import { addUrlToImageHashes } from "@/services/publications/utils/article-method" +import useArticles from "@/services/publications/hooks/useArticles" const ArticleItemContainer = styled(Box)({ background: palette.grays[50], @@ -58,21 +59,22 @@ export const ArticleItem: React.FC = React.memo( ({ article, couldUpdate, couldDelete, publicationSlug }) => { const ipfs = useIpfs() const navigate = useNavigate() - const { saveArticle, saveDraftArticle, setArticleEditorState, articleEditorState } = useArticleContext() + const { saveArticle, articleEditorState, articleFormMethods } = useArticleContext() const { setLastPathWithChainName } = usePosterContext() - const { deleteArticle } = usePoster() + // const { deleteArticle } = usePoster() const { description, image, title, tags, lastUpdated, id } = article - const { indexing, transactionCompleted, setExecutePollInterval, setCurrentArticleId } = - usePublication(publicationSlug) + const { indexing, transactionCompleted } = usePublication(publicationSlug) + const { deleteArticle, txLoading } = useArticles() const { imageSrc } = useArticle(article.id || "") const articleTitle = shortTitle(title, 30) const articleDescription = description && shortTitle(description, 165) const date = lastUpdated && new Date(parseInt(lastUpdated) * 1000) - const [loading, setLoading] = useState(false) + // const [txLoading.delete, setLoading] = useState(false) const [navigateEditArticle, setNavigateEditArticle] = useState(false) const [articleHtmlContent, setArticleHtmlContent] = useState(undefined) const isValidHash = useMemo(() => article && isIPFS.multihash(article.article), [article?.article]) + const { setValue } = articleFormMethods const decodeArticleContent = async () => { if (article.article) { if (isValidHash) { @@ -127,28 +129,28 @@ export const ArticleItem: React.FC = React.memo( const handleDeleteArticle = async () => { if (article && article.id && couldDelete) { - setLoading(true) - await deleteArticle({ - action: "article/delete", - id: article.id, - }).then((res) => { - setCurrentArticleId(article.id) - if (res && res.error) { - setLoading(false) - } else { - setExecutePollInterval(true) - } - }) + await deleteArticle(article.id) } } const handleEditArticle = async () => { + const post = { ...article } if (article) { - return await processArticleContent(article, ipfs, isValidHash).then(({ img, content, modifiedHTMLString }) => { - saveDraftArticle({ ...article, title: article.title, image: img }) - setArticleEditorState(modifiedHTMLString ?? content ?? undefined) - setNavigateEditArticle(true) - }) + setValue("id", post.id) + setValue("title", post.title) + setValue("article", addUrlToImageHashes(post.article)) + setValue("description", post.description ?? undefined) + setValue("lastUpdated", post.lastUpdated ?? undefined) + setValue( + "tags", + post.tags + ? post.tags.map((tag) => { + return { label: tag, value: tag } + }) + : undefined, + ) + setValue("image", post.image) + navigate(`./edit`) } } @@ -232,7 +234,7 @@ export const ArticleItem: React.FC = React.memo( variant="contained" size="small" startIcon={} - disabled={loading || indexing || !articleHtmlContent} + disabled={txLoading.delete || indexing || !articleHtmlContent} > Edit Article @@ -248,10 +250,10 @@ export const ArticleItem: React.FC = React.memo( }} variant="contained" size="small" - disabled={loading || indexing} + disabled={txLoading.delete || indexing} startIcon={} > - {loading && } + {txLoading.delete && } {indexing ? "Indexing..." : "Delete Article"} @@ -264,7 +266,7 @@ export const ArticleItem: React.FC = React.memo( color="primary" size="small" endIcon={} - disabled={loading || indexing || !articleHtmlContent} + disabled={txLoading.delete || indexing || !articleHtmlContent} onClick={() => { navigate(`./${id}`) saveArticle(article) diff --git a/packages/app/src/components/views/publication/components/ArticleSidebar.tsx b/packages/app/src/components/views/publication/components/ArticleSidebar.tsx index 355bdc68..a448a2cf 100644 --- a/packages/app/src/components/views/publication/components/ArticleSidebar.tsx +++ b/packages/app/src/components/views/publication/components/ArticleSidebar.tsx @@ -1,14 +1,14 @@ -import React, { SetStateAction } from "react" +import React, { SetStateAction, useEffect, useState } from "react" import { Box, InputLabel, Stack, TextField, Tooltip, Typography, useTheme } from "@mui/material" import { useArticleContext } from "@/services/publications/contexts" import { Close } from "@mui/icons-material" import { palette, typography } from "@/theme" import { UploadFile } from "@/components/commons/UploadFile" -// import LinkIcon from "@/assets/images/icons/link" import { CreatableSelect } from "@/components/commons/CreatableSelect" import useLocalStorage from "@/hooks/useLocalStorage" import { Pinning, PinningService } from "@/models/pinning" import { Controller } from "react-hook-form" +import { toBase64 } from "@/utils/string-handler" export interface ArticleSidebarProps { setShowSidebar: React.Dispatch> } @@ -17,69 +17,38 @@ const ArticleSidebar: React.FC = ({ setShowSidebar }) => { const [pinning] = useLocalStorage("pinning", undefined) const isDirectlyOnChain = pinning && pinning.service === PinningService.NONE const { article } = useArticleContext() - const { - draftArticle, - // saveDraftArticle, - // setDraftArticleThumbnail, - // draftArticleThumbnail, - // updateDraftArticle, - articleFormMethods, - } = useArticleContext() - // const [articleThumbnail, setArticleThumbnail] = useState() - // const [uriImage, setUriImage] = useState(undefined) - // const [postUrl, setPostUrl] = useState("this-is-a-test") + const { articleFormMethods } = useArticleContext() const { control, + getValues, setValue: setArticleFormValue, formState: { errors }, } = articleFormMethods - + const articleImage = getValues("image") const theme = useTheme() + const [thumbnailUri, setThumbnailUri] = useState(undefined) - // useEffect(() => { - // if (article?.tags?.length && !tags.length) { - // setTags(article.tags) - // } - - // if (draftArticle && (description === "" || !tags.length)) { - // if (draftArticle.tags && draftArticle.tags.length) { - // setTags(draftArticle.tags) - // } - // if (draftArticle.image) { - // setUriImage(draftArticle.image) - // } - // } - // }, [article, draftArticle, tags.length, description]) + useEffect(() => { + const transformImg = async () => { + if (typeof articleImage === "string") { + setThumbnailUri(`https://ipfs.io/ipfs/${articleImage}`) + } + if (articleImage) { + const base64Image = await toBase64(articleImage) + setThumbnailUri(base64Image) + } else { + setThumbnailUri(undefined) + } + } - // useEffect(() => { - // if (draftArticleThumbnail && !articleThumbnail) { - // setArticleThumbnail(draftArticleThumbnail) - // } - // }, [draftArticleThumbnail]) - - // useEffect(() => { - // if (draftArticle && uriImage) { - // setDraftArticleThumbnail(articleThumbnail) - // saveDraftArticle({ ...draftArticle, image: uriImage }) - // } - // }, [uriImage]) + transformImg() + }, [articleImage]) const handleClose = () => { setShowSidebar(false) } - // const handleOnFiles = (file: File | undefined) => { - // setDraftArticleThumbnail(file) - // setArticleThumbnail(file) - // if (!file && draftArticle) { - // setUriImage(undefined) - // updateDraftArticle("image", null) - // } - // } - - // const edited = true - return ( = ({ setShowSidebar }) => { Thumbnail setArticleFormValue("image", fileSelected)} - // convertedFile={setUriImage} + convertedFile={setThumbnailUri} disabled={isDirectlyOnChain} /> diff --git a/packages/app/src/components/views/publication/components/ArticlesSection.tsx b/packages/app/src/components/views/publication/components/ArticlesSection.tsx index 70761b84..90daba38 100644 --- a/packages/app/src/components/views/publication/components/ArticlesSection.tsx +++ b/packages/app/src/components/views/publication/components/ArticlesSection.tsx @@ -9,15 +9,23 @@ import usePublication from "@/services/publications/hooks/usePublication" import { ArticleItem } from "./ArticleItem" import { INITIAL_ARTICLE_VALUE, useArticleContext } from "@/services/publications/contexts" import { useWeb3ModalAccount } from "@web3modal/ethers5/react" +import useArticles from "@/services/publications/hooks/useArticles" export const ArticlesSection: React.FC = React.memo(() => { const navigate = useNavigate() const { address } = useWeb3ModalAccount() const { publicationSlug } = useParams<{ publicationSlug: string }>() - const { setMarkdownArticle, saveDraftArticle, saveArticle, setDraftArticleThumbnail, setArticleEditorState } = - useArticleContext() - const { data, refetch, publicationId } = usePublication(publicationSlug ?? "") - const articles = data && data.articles + const { + setMarkdownArticle, + saveDraftArticle, + saveArticle, + setDraftArticleThumbnail, + setArticleEditorState, + articles, + setArticles, + } = useArticleContext() + const { data, publicationId, refetch } = usePublication(publicationSlug ?? "") + const { data: articlesQueryData, refetch: refetchArticles } = useArticles() const permissions = data && data.permissions const havePermissionToCreate = permissions ? haveActionPermission(permissions, "articleCreate", address || "") : false const havePermissionToUpdate = permissions ? haveActionPermission(permissions, "articleUpdate", address || "") : false @@ -29,6 +37,18 @@ export const ArticlesSection: React.FC = React.memo(() => { } }, [refetch, data, publicationId]) + useEffect(() => { + if (!articlesQueryData) { + refetchArticles() + } + }, [articlesQueryData, refetchArticles]) + + useEffect(() => { + if (articlesQueryData?.length) { + setArticles(articlesQueryData) + } + }, [articlesQueryData, setArticles]) + if (articles && articles.length > 0) { return ( <> diff --git a/packages/app/src/constants/wallet.ts b/packages/app/src/constants/wallet.ts deleted file mode 100644 index 4152f1e0..00000000 --- a/packages/app/src/constants/wallet.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AbstractConnector } from "@web3-react/abstract-connector" -import METAMASK_ICON_URL from "../assets/images/metamask.svg" -import WALLET_CONNECT_ICON_URL from "../assets/images/walletConnect.svg" -import COINBASE_ICON_URL from "../assets/images/coinbaseIcon.svg" -import { injected, walletconnect, walletlink } from "../connectors" - -interface WalletInfo { - connector: AbstractConnector - name: string - iconURL: string -} - -export const SUPPORTED_WALLETS: WalletInfo[] = [ - { - connector: injected, - name: "Metamask", - iconURL: METAMASK_ICON_URL, - }, - { - connector: walletconnect, - name: "Wallet Connect", - iconURL: WALLET_CONNECT_ICON_URL, - }, - { - connector: walletlink, - name: "Coinbase Wallet", - iconURL: COINBASE_ICON_URL, - }, -] diff --git a/packages/app/src/hooks/useContract.tsx b/packages/app/src/hooks/useContract.tsx index da2618c8..d7124844 100644 --- a/packages/app/src/hooks/useContract.tsx +++ b/packages/app/src/hooks/useContract.tsx @@ -42,7 +42,6 @@ export const useExecuteTransaction = ( const message = !signer ? "Signer not available" : "Contract is not available" return { error: true, message } } - try { setStatus(TransactionStatus.Pending) const transactionMethod = contract![methodName] as ethers.ContractFunction diff --git a/packages/app/src/hooks/useIpfs.ts b/packages/app/src/hooks/useIpfs.ts index f7046c8a..e1faf700 100644 --- a/packages/app/src/hooks/useIpfs.ts +++ b/packages/app/src/hooks/useIpfs.ts @@ -2,8 +2,9 @@ import useLocalStorage from "./useLocalStorage" import { Pinning, PinningService } from "../models/pinning" import axios from "axios" import { useNotification } from "./useNotification" -import { getClient } from "../services/ipfs" -import { useIPFSContext } from "../services/ipfs/context" +// import { getClient } from "../services/ipfs" +import { useIPFSContext } from "@/services/ipfs/context" +// import { useIPFSContext } from "../services/ipfs/context" const IPFS_GATEWAY = import.meta.env.VITE_APP_IPFS_GATEWAY const INFURA_IPFS_API_KEY = import.meta.env.VITE_APP_INFURA_IPFS_API_KEY @@ -20,7 +21,7 @@ if (INFURA_IPFS_API_KEY_SECRET == null) { } export interface IpfsFunctions { - uploadContent: (file: File | string) => Promise<{ cid?: any; path: string }> + // uploadContent: (file: File | string) => Promise<{ cid?: any; path: string }> pinAction: (path: string, name: string, msg?: string) => Promise isValidIpfsService: (data: Pinning) => Promise getText: (hash: string) => Promise @@ -29,98 +30,98 @@ export interface IpfsFunctions { } export const useIpfs = (): IpfsFunctions => { - const [isSelectedHowToSaveArticle] = useLocalStorage("isSelectedHowToSaveArticle", undefined) + // const [isSelectedHowToSaveArticle] = useLocalStorage("isSelectedHowToSaveArticle", undefined) const [pinning] = useLocalStorage("pinning", undefined) - const [ipfsNodeEndpoint] = useLocalStorage("ipfsNodeEndpoint", undefined) - const { decodeCID } = useIPFSContext() + // const [ipfsNodeEndpoint] = useLocalStorage("ipfsNodeEndpoint", undefined) + const { decodeIpfsHash } = useIPFSContext() const openNotification = useNotification() // TODO: keeping until we find a better way to handle this - const getClientHack = async (ipfsNodeEndpoint?: string) => { - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - let client = await getClient(ipfsNodeEndpoint) + // const getClientHack = async (ipfsNodeEndpoint?: string) => { + // const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + // let client = await getClient(ipfsNodeEndpoint) - if (!client) { - await sleep(1000) - client = await getClient(ipfsNodeEndpoint) - } + // if (!client) { + // await sleep(1000) + // client = await getClient(ipfsNodeEndpoint) + // } - if (!client) { - await sleep(2000) - client = await getClient(ipfsNodeEndpoint) - } - return client - } + // if (!client) { + // await sleep(2000) + // client = await getClient(ipfsNodeEndpoint) + // } + // return client + // } - /** - * Uploads a file to IPFS and pins it via the Infura API - * @param {File | string} file - The file or string content to be uploaded to IPFS - * @returns {Promise<{ cid?: any; path: string } | undefined>} The CID and path of the file in IPFS, or undefined if an error occurs - */ - const uploadToInfura = async ( - file: File | string, - pin: boolean, - ): Promise<{ cid?: any; path: string } | undefined> => { - const formData = new FormData() - // Check if 'file' is a string or an instance of File/Blob - if (typeof file === "string") { - const blob = new Blob([file], { type: "text/plain" }) // Convert string to Blob - formData.append("file", blob) - } else { - formData.append("file", file) - } - const response = await axios.post(`https://ipfs.infura.io:5001/api/v0/add?pin=${pin}`, formData, { - headers: { - "Content-Type": "multipart/form-data", - Authorization: - "Basic " + Buffer.from(`${INFURA_IPFS_API_KEY}:${INFURA_IPFS_API_KEY_SECRET}`).toString("base64"), - }, - }) - // The returned data contains the CID of the file in IPFS, which is extracted and returned along with the path - let cid = response.data.Hash - return { - cid: cid, - path: cid, - } - } + // /** + // * Uploads a file to IPFS and pins it via the Infura API + // * @param {File | string} file - The file or string content to be uploaded to IPFS + // * @returns {Promise<{ cid?: any; path: string } | undefined>} The CID and path of the file in IPFS, or undefined if an error occurs + // */ + // const uploadToInfura = async ( + // file: File | string, + // pin: boolean, + // ): Promise<{ cid?: any; path: string } | undefined> => { + // const formData = new FormData() + // // Check if 'file' is a string or an instance of File/Blob + // if (typeof file === "string") { + // const blob = new Blob([file], { type: "text/plain" }) // Convert string to Blob + // formData.append("file", blob) + // } else { + // formData.append("file", file) + // } + // const response = await axios.post(`https://ipfs.infura.io:5001/api/v0/add?pin=${pin}`, formData, { + // headers: { + // "Content-Type": "multipart/form-data", + // Authorization: + // "Basic " + Buffer.from(`${INFURA_IPFS_API_KEY}:${INFURA_IPFS_API_KEY_SECRET}`).toString("base64"), + // }, + // }) + // // The returned data contains the CID of the file in IPFS, which is extracted and returned along with the path + // let cid = response.data.Hash + // return { + // cid: cid, + // path: cid, + // } + // } - /** - * Uploads a file or string content to IPFS - * @param {File | string} file - The file or string content to be uploaded to IPFS - * @returns {Promise<{ cid?: any; path: string }>} The CID and path of the file in IPFS - */ - const uploadContent = async (file: File | string): Promise<{ cid?: any; path: string }> => { - console.log("uploading content") - let result - - if (!pinning || (pinning && pinning?.service === PinningService.PUBLIC)) { - try { - result = await uploadToInfura(file, true) - } catch (infuraError) { - console.error("Failed to upload file using Infura API:", infuraError) - } - } - if (isSelectedHowToSaveArticle && pinning) { - try { - // First attempts to upload the content using the IPFS HTTP client - const client = await getClientHack(ipfsNodeEndpoint) - result = await client.add(file) - } catch (error) { - console.log("Failed to upload content using IPFS HTTP client:", error) - // If the upload fails, it attempts to upload the file using the Infura API. - // This is typically used when the user is using a public IPFS gateway, which does not support generating a CID. - try { - result = await uploadToInfura(file, false) - } catch (infuraError) { - console.error("Failed to upload file using Infura API:", infuraError) - } - } - } + // /** + // * Uploads a file or string content to IPFS + // * @param {File | string} file - The file or string content to be uploaded to IPFS + // * @returns {Promise<{ cid?: any; path: string }>} The CID and path of the file in IPFS + // */ + // const uploadContent = async (file: File | string): Promise<{ cid?: any; path: string }> => { + // console.log("uploading content") + // let result + + // if (!pinning || (pinning && pinning?.service === PinningService.PUBLIC)) { + // try { + // result = await uploadToInfura(file, true) + // } catch (infuraError) { + // console.error("Failed to upload file using Infura API:", infuraError) + // } + // } + // if (isSelectedHowToSaveArticle && pinning) { + // try { + // // First attempts to upload the content using the IPFS HTTP client + // // const client = await getClientHack(ipfsNodeEndpoint) + // result = await client.add(file) + // } catch (error) { + // console.log("Failed to upload content using IPFS HTTP client:", error) + // // If the upload fails, it attempts to upload the file using the Infura API. + // // This is typically used when the user is using a public IPFS gateway, which does not support generating a CID. + // try { + // result = await uploadToInfura(file, false) + // } catch (infuraError) { + // console.error("Failed to upload file using Infura API:", infuraError) + // } + // } + // } - return { - cid: result?.cid ?? "", - path: result?.path ?? "", - } - } + // return { + // cid: result?.cid ?? "", + // path: result?.path ?? "", + // } + // } const getImgSrc = async (hash: string): Promise => { // TODO: this is a workaround. It should use the ipfs http client @@ -161,7 +162,7 @@ export const useIpfs = (): IpfsFunctions => { // } // V2 with helia const getText = async (hash: string): Promise => { - const str = await decodeCID(hash) + const str = await decodeIpfsHash(hash) return str } @@ -220,7 +221,7 @@ export const useIpfs = (): IpfsFunctions => { } return { - uploadContent, + // uploadContent, pinAction, isValidIpfsService, getText, diff --git a/packages/app/src/hooks/useMonitorTransaction.tsx b/packages/app/src/hooks/useMonitorTransaction.tsx index 47cb505b..0bd06f36 100644 --- a/packages/app/src/hooks/useMonitorTransaction.tsx +++ b/packages/app/src/hooks/useMonitorTransaction.tsx @@ -8,6 +8,7 @@ export const useMonitorTransaction = ( variables: object, transactionType: TransactionType, elementKey: string, // key for the element in the data + defaultLastUpdated?: number | null, ) => { const [isIndexed, setIsIndexed] = useState(false) const previousLastUpdated = useRef(null) @@ -42,7 +43,7 @@ export const useMonitorTransaction = ( if (transactionType === "update" && element) { const currentLastUpdated = element.lastUpdated || null if (previousLastUpdated.current === null) { - previousLastUpdated.current = currentLastUpdated + previousLastUpdated.current = defaultLastUpdated ?? currentLastUpdated } else if (currentLastUpdated !== previousLastUpdated.current) { setIsIndexed(true) previousLastUpdated.current = currentLastUpdated @@ -56,7 +57,7 @@ export const useMonitorTransaction = ( if (error) { setIsIndexed(true) } - }, [data, fetching, error, variables, elementKey, transactionType]) + }, [data, fetching, error, variables, elementKey, transactionType, defaultLastUpdated]) useEffect(() => { if (!isIndexed && !fetching && areVariablesValid(variables)) { diff --git a/packages/app/src/hooks/useNotification.tsx b/packages/app/src/hooks/useNotification.tsx index 8abaf462..73add294 100644 --- a/packages/app/src/hooks/useNotification.tsx +++ b/packages/app/src/hooks/useNotification.tsx @@ -14,7 +14,7 @@ const ExpandIcon = styled(OpenInNewIcon)({ fontSize: 25, }) -const NotificationActions = ({ detailsLink, onClose }: { detailsLink?: string; onClose: () => void }) => ( +const NotificationActions: React.FC<{ detailsLink?: string; onClose: () => void }> = ({ detailsLink, onClose }) => ( {detailsLink ? ( diff --git a/packages/app/src/schemas/article.schema.ts b/packages/app/src/schemas/article.schema.ts index fba98112..9aebc436 100644 --- a/packages/app/src/schemas/article.schema.ts +++ b/packages/app/src/schemas/article.schema.ts @@ -3,9 +3,11 @@ import * as yup from "yup" export interface ArticleFormSchema { title: string article: string + id?: string description?: string tags?: { label: string; value: string }[] image?: File | undefined + lastUpdated?: string } export interface UpdateArticleFormSchema extends ArticleFormSchema { id: string @@ -14,7 +16,9 @@ export interface UpdateArticleFormSchema extends ArticleFormSchema { export const articleSchema = yup.object({ title: yup.string().required("Title is required"), article: yup.string().required("Article is required"), + id: yup.string().optional(), description: yup.string().optional(), + lastUpdated: yup.string().optional(), image: yup.mixed().optional(), tags: yup .array() diff --git a/packages/app/src/services/ipfs.ts b/packages/app/src/services/ipfs.ts deleted file mode 100644 index a314a641..00000000 --- a/packages/app/src/services/ipfs.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as ipfsCore from "ipfs-core" -import { create, IPFSHTTPClient } from "ipfs-http-client" - -type IPFS = IPFSHTTPClient | ipfsCore.IPFS - -let client: IPFS -let ipfsNodeEndpointCached: string | undefined - -const ipfsEndpoints = [ - // Node provided by user - ipfsNodeEndpointCached, - // Local node - "http://localhost:5001/api/v0", -] - -const publicIpfsEndpoints = [ - // Public nodes - "https://dweb.link/api/v0", - "https://gateway.ipfs.io/api/v0", - "https://ipfs.runfission.com/api/v0", -] - -export const getClient = async (ipfsNodeEndpoint?: string): Promise => { - if (ipfsNodeEndpointCached !== ipfsNodeEndpoint || client == null) { - ipfsNodeEndpointCached = ipfsNodeEndpoint - for (const endpoint of ipfsEndpoints) { - if (endpoint == null) { - continue - } - try { - const ipfsHttpClient = create({ url: endpoint }) - if (await ipfsHttpClient.version()) { - console.log(`IPFS getClient: using ipfsHttpClient at ${endpoint}`) - client = ipfsHttpClient - return client - } else { - console.log(`Unable to connect to IPFS node at ${endpoint}`) - } - } catch (ipfsHttpClientError) { - console.log(`Failed to connect to IPFS node at ${endpoint}`) - } - } - - for (const endpoint of publicIpfsEndpoints) { - try { - const ipfsHttpClient = create({ url: endpoint }) - if (await ipfsHttpClient.version()) { - console.log(`IPFS getClient: using ipfsHttpClient at ${endpoint}`) - client = ipfsHttpClient - return client - } else { - console.log(`Unable to connect to IPFS node at ${endpoint}`) - } - } catch (ipfsHttpClientError) { - console.log(`Failed to connect to IPFS node at ${endpoint}`) - } - } - - // TODO: ipfs-js is deprecated, the alternative suggested is https://github.com/ipfs/helia - // If none of the HTTP client options are available, we spin up an IPFS node in the browser. - // try { - // const ipfsBrowser = await ipfsCore.create({ start: true }) - // if (ipfsBrowser.isOnline()) { - // console.log("IPFS getClient: using ipfsBrowser") - // client = ipfsBrowser - // return client - // } else { - // console.log("IPFS setupClient: ipfsBrowser is not online") - // throw Error("Unable to connect to IPFS node in browser.") - // } - // } catch (ipfsBrowserError) { - // if (ipfsBrowserError instanceof Error && ipfsBrowserError.name === "LockExistsError") { - // console.log("IPFS setupClient: ipfsBrowser is already running") - // } else { - // console.log("Unable to connect to a running IPFS node.") - // console.log("Will try to connect to a public IPFS node.") - // for (const endpoint of publicIpfsEndpoints) { - // try { - // const ipfsHttpClient = create({ url: endpoint }) - // if (await ipfsHttpClient.version()) { - // console.log(`IPFS getClient: using ipfsHttpClient at ${endpoint}`) - // client = ipfsHttpClient - // return client - // } else { - // console.log(`Unable to connect to IPFS node at ${endpoint}`) - // } - // } catch (ipfsHttpClientError) { - // console.log(`Failed to connect to IPFS node at ${endpoint}`) - // } - // } - // } - // } - } - - return client -} diff --git a/packages/app/src/services/poster/hooks/usePoster.ts b/packages/app/src/services/poster/hooks/usePoster.ts index cc444c3c..35dcece6 100644 --- a/packages/app/src/services/poster/hooks/usePoster.ts +++ b/packages/app/src/services/poster/hooks/usePoster.ts @@ -36,7 +36,7 @@ const usePoster = () => { const [pinning] = useLocalStorage("pinning", undefined) const [loading, setLoading] = useState(false) const { pinAction } = useIpfs() - const [isValidChain, setIsValidChain] = useState(false) + // const [isValidChain, setIsValidChain] = useState(false) const [properlyNetwork, setProperlyNetwork] = useState(null) const parameters = chainParameters(chainId ? chainId : SupportedChainId.SEPOLIA) const URL = parameters ? parameters.blockExplorerUrls[0] : "https://sepolia.etherscan.io/tx/" @@ -44,7 +44,7 @@ const usePoster = () => { useEffect(() => { if (chainId != null) { const validationResult = checkIsValidChain(chainId, publicationChainId) - setIsValidChain(validationResult.isValid) + // setIsValidChain(validationResult.isValid) setProperlyNetwork(validationResult.network) } }, [publicationChainId, chainId]) diff --git a/packages/app/src/services/publications/contexts/article.context.tsx b/packages/app/src/services/publications/contexts/article.context.tsx index dbdfedc6..82d60c7a 100644 --- a/packages/app/src/services/publications/contexts/article.context.tsx +++ b/packages/app/src/services/publications/contexts/article.context.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { useIpfs } from "../../../hooks/useIpfs" import { Article } from "../../../models/publication" import { createGenericContext } from "../../../utils/create-generic-context" @@ -8,6 +8,7 @@ import { uid } from "uid" import { useForm } from "react-hook-form" import { yupResolver } from "@hookform/resolvers/yup" import { articleSchema } from "@/schemas/article.schema" +import { useLocation } from "react-router-dom" export const INITIAL_ARTICLE_VALUE = { title: "", article: "" } export const INITIAL_ARTICLE_BLOCK = [{ id: uid(), html: "", tag: "p" }] @@ -15,9 +16,11 @@ const [useArticleContext, ArticleContextProvider] = createGenericContext
{ const ipfs = useIpfs() + const location = useLocation() const [currentPath, setCurrentPath] = useState(undefined) const [draftArticle, setDraftArticle] = useState
(INITIAL_ARTICLE_VALUE) const [article, setArticle] = useState
(undefined) + const [articles, setArticles] = useState(undefined) const [executeArticleTransaction, setExecuteArticleTransaction] = useState(false) const [draftArticleThumbnail, setDraftArticleThumbnail] = useState(undefined) @@ -50,6 +53,26 @@ const ArticleProvider = ({ children }: ArticleProviderProps) => { }) const [articleHtml, setArticleHtml] = useState(undefined) + //Clear form + useEffect(() => { + const createArticleRegex = /\/new$/ + const editArticleRegex = /\/edit$/ + const previewArticleRegex = /\/preview$/ + if ( + !createArticleRegex.test(location.pathname) && + !editArticleRegex.test(location.pathname) && + !previewArticleRegex.test(location.pathname) + ) { + articleFormMethods.reset({ + title: "", + article: "", + description: "", + tags: [], + image: undefined, + }) + } + }, [location.pathname, articleFormMethods]) + const clearArticleState = () => { setCurrentPath(undefined) setDraftArticle(undefined) @@ -134,6 +157,8 @@ const ArticleProvider = ({ children }: ArticleProviderProps) => { clearArticleState, contentImageFiles, setContentImageFiles, + setArticles, + articles, }} > {children} diff --git a/packages/app/src/services/publications/contexts/article.types.ts b/packages/app/src/services/publications/contexts/article.types.ts index 1896871e..a760127c 100644 --- a/packages/app/src/services/publications/contexts/article.types.ts +++ b/packages/app/src/services/publications/contexts/article.types.ts @@ -7,6 +7,8 @@ import { articleSchema } from "@/schemas/article.schema" export type ArticleContextType = { draftArticle: Article | undefined article: Article | undefined + articles: Article[] | undefined + setArticles: (article: Article[] | undefined) => void draftArticleThumbnail: File | undefined currentPath: string | undefined markdownArticle: string | undefined @@ -46,7 +48,7 @@ export type ArticleContextType = { setContentImageFiles: (files: File[] | undefined) => void articleHtml: string | undefined setArticleHtml: React.Dispatch> - articleFormMethods: UseFormReturn>; + articleFormMethods: UseFormReturn> } export type ArticleProviderProps = { diff --git a/packages/app/src/services/publications/hooks/useArticles.ts b/packages/app/src/services/publications/hooks/useArticles.ts index ca1a9198..d43b77ce 100644 --- a/packages/app/src/services/publications/hooks/useArticles.ts +++ b/packages/app/src/services/publications/hooks/useArticles.ts @@ -1,10 +1,8 @@ -import { maxBy } from "lodash" import { useCallback, useEffect, useState } from "react" import { useQuery } from "urql" import { useNotification } from "@/hooks/useNotification" -import { Article, Publication } from "@/models/publication" -import { usePosterContext } from "@/services/poster/context" -import { INITIAL_ARTICLE_VALUE, useArticleContext } from "@/services/publications/contexts" +import { Article } from "@/models/publication" +import { useArticleContext } from "@/services/publications/contexts" import { GET_ARTICLES_QUERY, GET_ARTICLE_QUERY } from "@/services/publications/queries" import { useWalletContext } from "@/connectors/WalletProvider" import { TransactionResult, useExecuteTransaction } from "@/hooks/useContract" @@ -18,6 +16,7 @@ import { generateUpdateArticleBody, deleteArticleBody, } from "@/services/publications/utils/article-method" +import { useNavigate, useParams } from "react-router-dom" interface TransactionBody extends Object { image?: string @@ -25,14 +24,12 @@ interface TransactionBody extends Object { } const useArticles = () => { + const navigate = useNavigate() const openNotification = useNotification() - // const { transactionUrl } = usePosterContext() const { encodeIpfsHash, remotePin } = useIPFSContext() - const { saveArticle } = useArticleContext() + const { saveArticle, setArticles } = useArticleContext() + const { publicationSlug } = useParams<{ publicationSlug: string }>() const [data, setData] = useState(undefined) - // const [indexing, setIndexing] = useState(false) - // const [executePollInterval, setExecutePollInterval] = useState(false) - // const [transactionCompleted, setTransactionCompleted] = useState(false) const { signer } = useWalletContext() const [txLoading, setTxLoading] = useState({ create: false, @@ -42,6 +39,7 @@ const useArticles = () => { const [newArticleId, setNewArticleId] = useState() const [articleIdToDelete, setArticleIdToDelete] = useState("") const [articleIdToUpdate, setArticleIdToUpdate] = useState("") + const [lastUpdated, setLastUpdated] = useState(null) const { executeTransaction, status, errorMessage } = useExecuteTransaction( signer, POSTER_CONTRACT, @@ -74,6 +72,7 @@ const useArticles = () => { }, "update", "article", + lastUpdated, ) const [{ data: result, fetching: loading }, executeQuery] = useQuery({ @@ -85,36 +84,39 @@ const useArticles = () => { useEffect(() => { if (result) { setData(result.articles) + setArticles(result.articles) } else { setData(undefined) } - }, [result]) + }, [result, setArticles]) useEffect(() => { if (newArticleIndexed && newArticleId) { setTxLoading((prev) => ({ ...prev, create: false })) - console.log("newArticleId", newArticleId) - // navigate(`/${newArticleId}`) + navigate(`/${publicationSlug}/${newArticleId}`) refetch() } - }, [newArticleId, newArticleIndexed, refetch]) + }, [navigate, newArticleId, newArticleIndexed, publicationSlug, refetch]) useEffect(() => { if (articleDeletedIndexed && articleIdToDelete) { setTxLoading((prev) => ({ ...prev, delete: false })) setArticleIdToDelete("") - // navigate(`/publications`) refetch() } - }, [articleDeletedIndexed, articleIdToDelete, refetch]) + }, [articleDeletedIndexed, articleIdToDelete, refetch, setArticles]) useEffect(() => { if (articleUpdateIndexed && articleUpdateFields) { + const { article } = articleUpdateFields as { article: Article } + setTxLoading((prev) => ({ ...prev, update: false })) setArticleIdToUpdate("") - saveArticle(articleUpdateFields as Article) + setLastUpdated(null) + navigate(`/${publicationSlug}/${article.id}`) + saveArticle(article) } - }, [articleUpdateIndexed, articleUpdateFields, saveArticle]) + }, [articleUpdateIndexed, articleUpdateFields, saveArticle, navigate, publicationSlug]) const handleTransaction = async ( transactionBody: TransactionBody, @@ -122,7 +124,6 @@ const useArticles = () => { callback: (result: TransactionResult) => void, ) => { try { - console.log("transactionBody", transactionBody) setTxLoading({ ...txLoading, [transactionType]: true }) const result = await executeTransaction(JSON.stringify(transactionBody), "PUBLICATION") @@ -163,6 +164,7 @@ const useArticles = () => { const body = await generateUpdateArticleBody(publicationId, fields, encodeIpfsHash) handleTransaction({ ...body.articleBody, imgHashes: body.imgHashes }, "update", () => { setArticleIdToUpdate(fields.id) + setLastUpdated(parseInt(fields.lastUpdated ?? "")) }) } @@ -173,75 +175,9 @@ const useArticles = () => { }) } - // //Execute poll interval to know the latest publications indexed - // useEffect(() => { - // if (executePollInterval) { - // setIndexing(true) - // const interval = setInterval(() => { - // refetch() - // }, 5000) - // return () => clearInterval(interval) - // } else { - // setIndexing(false) - // } - // }, [executePollInterval, refetch]) - - // //Execute poll interval to know is the last article created is already indexed - // useEffect(() => { - // if (data && data.length && executePollInterval && draftArticle) { - // const recentArticle = maxBy(data, (fetchedArticle) => { - // if (fetchedArticle.lastUpdated) { - // return parseInt(fetchedArticle.lastUpdated) - // } - // }) - // if (recentArticle && recentArticle.title === draftArticle.title) { - // setNewArticleId(recentArticle.id) - // saveDraftArticle(INITIAL_ARTICLE_VALUE) - // saveArticle(recentArticle) - // setTransactionCompleted(true) - // setIndexing(false) - // setExecutePollInterval(false) - // openNotification({ - // message: "Execute transaction confirmed!", - // autoHideDuration: 5000, - // variant: "success", - // detailsLink: transactionUrl, - // preventDuplicate: true, - // }) - // return - // } - // } - // }, [ - // loading, - // data, - // openNotification, - // transactionUrl, - // executePollInterval, - // draftArticle, - // saveArticle, - // saveDraftArticle, - // ]) - - // //Show toast when transaction is indexing - // useEffect(() => { - // if (indexing && transactionUrl && showToast) { - // setShowToast(false) - // openNotification({ - // message: "The transaction is indexing", - // autoHideDuration: 2000, - // variant: "info", - // detailsLink: transactionUrl, - // preventDuplicate: true, - // }) - // } - // }, [indexing, openNotification, showToast, transactionUrl]) - return { loading, data, - // indexing, - // transactionCompleted, - // setExecutePollInterval, txLoading, status, errorMessage, diff --git a/packages/app/src/services/publications/utils/article-method.ts b/packages/app/src/services/publications/utils/article-method.ts index b994ed01..8d56a7df 100644 --- a/packages/app/src/services/publications/utils/article-method.ts +++ b/packages/app/src/services/publications/utils/article-method.ts @@ -18,6 +18,25 @@ export const extractAndReplaceImageHashes = (htmlString: string): { hashes: stri const modifiedHtml = new XMLSerializer().serializeToString(doc.documentElement) return { hashes, modifiedHtml } } +export const addUrlToImageHashes = (htmlString: string): string => { + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + + // Get all the img elements in the document + const images = doc.getElementsByTagName('img'); + + // Update the src attribute for each img element + for (let img of images) { + const src = img.getAttribute('src'); + if (src && !src.startsWith('https://ipfs.io/ipfs/')) { + img.setAttribute('src', `https://ipfs.io/ipfs/${src}`); + } + } + + // Serialize the DOM object back into a string + return new XMLSerializer().serializeToString(doc); +} const processArticleBody = async ( publicationId: string, diff --git a/packages/app/src/theme/index.ts b/packages/app/src/theme/index.ts index b14974ac..674ef4d2 100644 --- a/packages/app/src/theme/index.ts +++ b/packages/app/src/theme/index.ts @@ -401,6 +401,7 @@ let theme = createTheme({ font-family: ${typography.body1.fontFamily}; font-size: ${typography.body1.fontSize}; line-height: ${typography.body1.lineHeight}; + min-height: 28px; } .divider { height: 28px; diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 83d4103c..f0a30bbf 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" -import browserslistToEsbuild from "browserslist-to-esbuild" +// import browserslistToEsbuild from "browserslist-to-esbuild" import svgr from "@svgr/rollup" import path from "path" import tsconfigPaths from "vite-tsconfig-paths" @@ -14,7 +14,7 @@ export default defineConfig({ }, build: { // --> ["chrome79", "edge92", "firefox91", "safari13.1"] - target: browserslistToEsbuild(), + target: "esnext", }, resolve: { alias: [{ find: "@", replacement: path.resolve(__dirname, "src") }], diff --git a/packages/app/yarn.lock b/packages/app/yarn.lock index c0c971d3..c0af311f 100644 --- a/packages/app/yarn.lock +++ b/packages/app/yarn.lock @@ -4039,6 +4039,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.3.2.tgz#ebe13406db8e7d906ad80ad7640b2e80dd6e92ce" integrity sha512-Mdc0qOPeJxxt5kSYKpNs7TzbQHeVpbpxwafUrxrvfD2iOnJlwlNxVWsVulc1t5EA8NpbTqYJTPmAtv2h/qmsfw== +"@tiptap/extension-bold@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.4.0.tgz#b5ced2c3bf51f304890137dbdf394d58c01eb208" + integrity sha512-csnW6hMDEHoRfxcPRLSqeJn+j35Lgtt1YRiOwn7DlS66sAECGRuoGfCvQSPij0TCDp4VCR9if5Sf8EymhnQumQ== + "@tiptap/extension-bubble-menu@^2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.3.2.tgz#58580c93b08b8953d2f9a98b35f7fd86decc252a" @@ -4066,6 +4071,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.3.2.tgz#36689994b76550e068ca9cc29cc8721e441bf2b5" integrity sha512-LyIRBFJCxbgi96ejoeewESvfUf5igfngamZJK+uegfTcznimP0AjSWs3whJwZ9QXUsQrB9tIrWIG4GBtatp6qw== +"@tiptap/extension-code@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.4.0.tgz#3a9fed3585bf49f445505c2e9ad71fd66e117304" + integrity sha512-wjhBukuiyJMq4cTcK3RBTzUPV24k5n1eEPlpmzku6ThwwkMdwynnMGMAmSF3fErh3AOyOUPoTTjgMYN2d10SJA== + "@tiptap/extension-color@^2.1.7": version "2.3.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-color/-/extension-color-2.3.2.tgz#341a99c77fe43b67e3907194224b144e08ecae95" @@ -4076,6 +4086,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.3.2.tgz#8914f952c946d150398913f1801295f101ded179" integrity sha512-EQcfkvA7lkZPKllhGo2jiEYLJyXhBFK7++oRatgbfgHEJ2uLBGv6ys7WLCeRA/ntcaWTH3rlS+HR/Y8/nnyQYg== +"@tiptap/extension-document@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.4.0.tgz#a396b2cbcc8708aa2a0a41d0be481fda4b61c77b" + integrity sha512-3jRodQJZDGbXlRPERaloS+IERg/VwzpC1IO6YSJR9jVIsBO6xC29P3cKTQlg1XO7p6ZH/0ksK73VC5BzzTwoHg== + "@tiptap/extension-dropcursor@^2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.3.2.tgz#3f12430fb5fa7e436726c66c53fe0334f97e1834" @@ -4150,6 +4165,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.3.2.tgz#2141b3fcf3ca74cadf8703c07582585407de723d" integrity sha512-bKzL4NXp0pDM/Q5ZCpjLxjQU4DwoWc6CDww1M4B4dp1sfiXiE2P7EOCMM2TfJOqNPUFpp5RcFKKcxC2Suj8W4w== +"@tiptap/extension-paragraph@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.4.0.tgz#5b9aea8775937b327bbe6754be12ae3144fb09ff" + integrity sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw== + "@tiptap/extension-placeholder@2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.0.3.tgz#69575353f09fc7524c9cdbfbf16c04f73c29d154" @@ -4180,11 +4200,23 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.3.2.tgz#000c1c4d5ae21b8ddef777b8298b67260e13274f" integrity sha512-a3whwDyyOsrmOQbfeY+Fm5XypSRgT3IGqWgz0r4U7oko57/X6Env08F1Ie2e2UkQw9B1MoW9cm3dC6jvrdzzYA== +"@tiptap/extension-text@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.4.0.tgz#a3a5f45a9856d513e574f24e2c9b6028273f8eb3" + integrity sha512-LV0bvE+VowE8IgLca7pM8ll7quNH+AgEHRbSrsI3SHKDCYB9gTHMjWaAkgkUVaO1u0IfCrjnCLym/PqFKa+vvg== + "@tiptap/extension-underline@^2.1.7": version "2.3.2" resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.3.2.tgz#52ad67365c604a9afeb63600a30d13141594278a" integrity sha512-ZmhWG8gMXk62AhpIMuOofe8GWbkXBW1uYHG55Q9r7MmglESLJm13S5k8JVfOmOMKGzfE23A6yQkojnksAiSGoQ== +"@tiptap/html@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@tiptap/html/-/html-2.4.0.tgz#0fad860303e7f34b43d64ca8bfbf8ab260d33164" + integrity sha512-iM0sa6t0Hb5GTXnjdKvMDtD3KZgA4Mwx3QADeqfR10EjfPNlkh/BHU83oIhss/2JVRBXiUUDnNxW9cfpHX37/g== + dependencies: + zeed-dom "^0.10.9" + "@tiptap/pm@^2.1.7": version "2.3.2" resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-2.3.2.tgz#3da84a19bf141ebfbea7b38aec7fc2bbdf8b3690" @@ -13503,6 +13535,13 @@ yup@^0.32.11: property-expr "^2.0.4" toposort "^2.0.2" +zeed-dom@^0.10.9: + version "0.10.11" + resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.10.11.tgz#36c7ded1aa4e638794049d90d1f6b0369000c9ac" + integrity sha512-7ukbu6aQKx34OQ7PfUIxOuAhk2MvyZY/t4/IJsVzy76zuMzfhE74+Dbyp8SHiUJPTPkF0FflP1KVrGJ7gk9IHw== + dependencies: + css-what "^6.1.0" + zod-to-json-schema@3.22.5: version "3.22.5" resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.22.5.tgz#3646e81cfc318dbad2a22519e5ce661615418673"