diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347fdf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# .gitignore +node_modules/ +dist/ +*.log +yarn.lock diff --git a/README.md b/README.md index af41f2c..f970ce9 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# sunbird-videoreel \ No newline at end of file +# Installation + +npm install sunbird-video-reel-pckg + +# OR + +yarn add sunbird-video-reel-pckg + +# Usage Example + +import React from "react"; +import { VideoReel } from "sunbird-video-reel-pckg"; + +const App = () => { +const items = [ +{ +"name": "", +"courseId": "", +"contentId": "", +"lesson_questionset": "", +}, +// Additional lesson objects... +]; + + +return ( + +); +}; + +export default App; + +### Props Table + +| Prop | Type | Description | +|--------------------|-----------|---------------------------------------------| +| `playerLink` | `string` | URL of the video player | +| `baseUrl` | `string` | API base URL for video content | +| `contentSource` | `string` | Source of the content (e.g., sunbird) | +| `items` | `array` | Array of lesson objects | +| `telemetryURL` | `string` | URL for telemetry events | +| `telemetryEndpoint` | `string` | API endpoint for telemetry data | +| `getTelemetry` | `function` | Callback function for telemetry events | + + +# API Reference + +Sunbird Scrollable Video Component: + + Displays a sequence of video lessons based on the provided items array. + + Sends telemetry events using the getTelemetry function. + + Uses an external video player hosted at playerLink. diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c879c23 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = { + presets: [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript" + ] +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..08cf975 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,73 @@ +// /** @type {import('ts-jest').JestConfigWithTsJest} **/ +// export default { +// preset: "ts-jest", +// testEnvironment: "jsdom", +// rootDir: './', // Ensure root directory is correct +// // transform: { +// // "^.+\\.tsx?$": ["ts-jest", {}], +// // }, +// // transform: { +// // "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", +// // }, +// // moduleNameMapper: { +// // "^react/jsx-runtime$": "react/jsx-runtime", +// // }, +// // setupFilesAfterEnv: [ +// // '@testing-library/jest-dom', // This should be enough to add custom matchers +// // ], + +// transform: { +// "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", +// }, +// moduleNameMapper: { +// "^react/jsx-runtime$": "react/jsx-runtime", +// }, +// transformIgnorePatterns: ["/node_modules/(?!some-es6-package)"], +// }; + + +// /** @type {import('ts-jest').JestConfigWithTsJest} **/ +// module.exports = { +// preset: "ts-jest", +// testEnvironment: "jsdom", +// rootDir: './', // Ensure root directory is correct + +// transform: { +// "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", +// }, +// moduleNameMapper: { +// "^react/jsx-runtime$": "react/jsx-runtime", +// }, +// transformIgnorePatterns: ["/node_modules/(?!some-es6-package)"], +// }; + + +// module.exports = { +// preset: "ts-jest", +// testEnvironment: "jsdom", +// rootDir: './', +// transform: { +// "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", +// }, +// moduleDirectories: ["node_modules", "src"], // Ensure Jest can find React +// moduleNameMapper: { +// "^react/jsx-runtime$": "react/jsx-runtime", +// }, +// transformIgnorePatterns: ["/node_modules/(?!some-es6-package)"], +// }; + + +module.exports = { + preset: "ts-jest", + testEnvironment: "jsdom", + rootDir: "./", + transform: { + "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", + }, + moduleDirectories: ["node_modules", "/src"], // Ensure Jest can find React + moduleNameMapper: { + "^react$": "/node_modules/react", + "^react/jsx-runtime$": "react/jsx-runtime", + }, + transformIgnorePatterns: ["/node_modules/(?!some-es6-package)"], +}; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..61c1faf --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "sunbird-scrollable-videos", + "version": "1.1.0", + "description": "A description of my new package", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "rollup -c", + "start": "rollup -c --watch", + "test": "jest" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "license": "MIT", + "dependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "13.4.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^29.5.14", + "@types/react": "^17.0.0 || ^18.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0", + "@types/react-virtualized": "^9.22.0", + "is-mobile": "^5.0.0", + "jest": "^29.7.0", + "jwt-decode": "^4.0.0", + "lodash": "4.17.21", + "react-window": "^1.8.11", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1" + }, + "devDependencies": { + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@rollup/plugin-commonjs": "^28.0.2", + "@rollup/plugin-node-resolve": "^16.0.0", + "@types/is-mobile": "^2.1.4", + "@types/jwt-decode": "^3.1.0", + "@types/lodash": "^4.17.15", + "@types/node": "^22.13.1", + "@types/react-window": "^1.8.8", + "babel-jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "rollup": "^4.34.6", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.36.0", + "ts-jest": "^29.2.5" + } +} diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 0000000..1959f0f --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,29 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from 'rollup-plugin-typescript2'; +import { terser } from 'rollup-plugin-terser'; + +export default { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/index.esm.js', + format: 'esm', + sourcemap: true, + }, + ], + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + }), + terser(), + ], + external: ['react', 'react-dom'], +}; \ No newline at end of file diff --git a/src/common/TopIcon.tsx b/src/common/TopIcon.tsx new file mode 100644 index 0000000..5943683 --- /dev/null +++ b/src/common/TopIcon.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +export const TopIcon = React.memo<{ + onClick: () => void; + icon: string; + right?: string; + left?: string; + zIndex?: string; + top?: string; + bottom?: string; + transition?: string; + rounded?: string; + roundedLeft?: string; + size?: string; + bg?: string; + p?: string; +}>(({ onClick, left, icon, right, bottom, top, transition, bg, size, p }) => { + return ( + + ); +}); diff --git a/src/common/useDeviceSize.tsx b/src/common/useDeviceSize.tsx new file mode 100644 index 0000000..472d3bd --- /dev/null +++ b/src/common/useDeviceSize.tsx @@ -0,0 +1,22 @@ +import { useState, useEffect } from "react"; + +export const useDeviceSize = () => { + const [size, setSize] = useState({ + height: window.innerHeight, + width: window.innerWidth, + }); + + useEffect(() => { + const handleResize = () => { + setSize({ + height: window.innerHeight, + width: window.innerWidth, + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return size; +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b65ce4d --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default as VideoReel } from "../src/video-reel-component/VideoReel"; diff --git a/src/services/content.service.ts b/src/services/content.service.ts new file mode 100644 index 0000000..23283b8 --- /dev/null +++ b/src/services/content.service.ts @@ -0,0 +1,49 @@ +interface IGetOneParams { + id: string; + contentSource: string; + type: string; + header?: Record; + baseUrl: string; +} + +const URL = { + CONTENT_ID: "/hierarchy/contentid", +}; + +export const getOne = async ({ + id, + contentSource, + type, + header, + baseUrl +}: IGetOneParams) => { + const headers = new Headers({ + ...header, + Authorization: `Bearer ${localStorage.getItem("token")}`, + }); + + try { + const response = await fetch( + `${baseUrl}/course/${contentSource}${URL.CONTENT_ID}?courseId=${id}&type=${type}`, + { + method: "GET", + headers, + } + ); + + if (response.ok) { + const result = await response.json(); + return result?.data || {}; + } else { + console.log("Failed to fetch data"); + return {}; + } + } catch (e: unknown) { + if (e instanceof Error) { + console.log("course/hierarchy/contentid", e.message); + } else { + console.log("course/hierarchy/contentid", String(e)); + } + return {}; + } +}; diff --git a/src/services/telemetry.ts b/src/services/telemetry.ts new file mode 100644 index 0000000..4a579a1 --- /dev/null +++ b/src/services/telemetry.ts @@ -0,0 +1,22 @@ + +export const commonFetchCall = async (dataString: string , telemetryURL :any, telemetryEndpoint:any) => { + return await fetch(`${telemetryURL}${telemetryEndpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + body: dataString, + }); +}; + +export const callBatch = async (dataString: Array , telemetryURL :any, telemetryEndpoint:any ) => { + return await commonFetchCall( + JSON.stringify({ + id: "palooza.telemetry", + ver: "3.0", + ets: Date.now(), + events: dataString, + }),telemetryURL ,telemetryEndpoint + ); +}; diff --git a/src/services/utilService.ts b/src/services/utilService.ts new file mode 100644 index 0000000..63dbede --- /dev/null +++ b/src/services/utilService.ts @@ -0,0 +1,29 @@ +// utility service + +import {jwtDecode} from "jwt-decode"; + +export function uniqueId(length = 32) { + let result = ""; + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +export function getSid() { + if (!localStorage.getItem("token")) { + return ""; + } + const tokenDecoded: any = jwtDecode(localStorage.getItem("token") || ""); + const date = new Date( + Date.now() + new Date().getTimezoneOffset() * 60 * 1000 + ); + return `${tokenDecoded.sub}_${date.getDate()}-${ + date.getMonth() + 1 + }-${date.getFullYear()}-${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}_${ + tokenDecoded.session_state + }`; +} diff --git a/src/services/utils.tsx b/src/services/utils.tsx new file mode 100644 index 0000000..46fd27a --- /dev/null +++ b/src/services/utils.tsx @@ -0,0 +1,262 @@ +import isMobile from "is-mobile"; +import { getSid } from "./utilService"; + +// const VITE_APP_ID = import.meta.env.VITE_APP_ID; +// const VITE_APP_VER = import.meta.env.VITE_APP_VER; +// const VITE_APP_PID = import.meta.env.VITE_APP_PID; + +export const getTrackData = (data: any) => { + let scoreDetails: any; + if ( + data?.iframeId === "assessment" && + ["iconUp", "iconDown"].includes(data?.action) + ) { + const heightPerItem = data?.action === "iconDown" ? 8 : 3; + return { type: "height", data: heightPerItem }; + } else if ( + ["assessment", "SelfAssess", "QuestionSet", "QuestionSetImage"].includes( + data.type + ) + ) { + scoreDetails = handleTrackData(data, "assessment"); + } else if (["application/vnd.sunbird.questionset"].includes(data.mimeType)) { + scoreDetails = handleTrackData(data, "questionset"); + } else if ( + [ + "application/pdf", + "video/mp4", + "video/webm", + "video/x-youtube", + "application/vnd.ekstep.h5p-archive", + ].includes(data.mimeType) || + data.type === "content" + ) { + scoreDetails = handleTrackData(data, "pdf-video"); + } else if (["application/vnd.ekstep.ecml-archive"].includes(data.mimeType)) { + const score = Array.isArray(data) + ? data.reduce((sum, item) => sum + (item.score || 0), 0) + : 0; + scoreDetails = handleTrackData({ ...data, score }, "ecml"); + } + return { ...scoreDetails, type: data.type, iframeId: data?.iframeId }; +}; + +export const handleTrackData = ( + trackData: any, + playerType: string = "quml" +) => { + let data = {}; + let scoreDetails; + const { mimeType } = trackData; + const typeMatch = mimeType.match(/\/(.+)$/); + const fileType = typeMatch ? typeMatch[1] : null; + + if (playerType === "quml") { + const newFormatData = trackData.reduce((oldData: any, newObj: any) => { + const dataExist = oldData.findIndex( + (e: any) => e.sectionId === newObj["item"]["sectionId"] + ); + if (dataExist >= 0) { + oldData[dataExist]["data"].push(newObj); + } else { + oldData = [ + ...oldData, + { + sectionId: newObj["item"]["sectionId"], + sectionName: newObj["sectionName"] ? newObj["sectionName"] : "", + data: [newObj], + }, + ]; + } + return oldData; + }, []); + scoreDetails = JSON.stringify(newFormatData); + data = { + status: "completed", + contentType: fileType, + timeSpent: trackData?.trackData?.duration, + score: trackData?.score, + scoreDetails, + }; + } else { + scoreDetails = JSON.stringify(trackData); + data = { + status: "completed", + contentType: fileType, + timeSpent: trackData?.duration || trackData?.trackData?.duration, + score: trackData?.score || 0, + scoreDetails, + }; + } + return { data, type: playerType }; +}; + +export const handleEvent = ({ data }: any) => { + let trackData: any; + if (["iconUp", "iconDown"].includes(data?.action)) { + trackData = data; + } + let telemetry: { + iframeId?: string; + eid?: string; + edata?: any; + } = {}; + try { + if (data && typeof data?.data === "string") { + telemetry = JSON.parse(data.data); + } else if (data && typeof data === "string") { + telemetry = JSON.parse(data); + } else if (data?.eid) { + telemetry = data; + } + } catch (e) { + console.log("Error parsing telemetry data", e); + } + + if (telemetry?.eid === "EXDATA") { + try { + const edata = JSON.parse(telemetry.edata?.data); + if (edata?.statement?.result) { + trackData = [...trackData, edata?.statement]; + } + } catch (e: unknown) { + console.log( + "telemetry format h5p is wrong", + e instanceof Error ? e.message : String(e) + ); + } + } + if (telemetry?.eid === "ASSESS") { + const edata = telemetry?.edata; + const sectionName = data?.children?.find( + (e: any) => e?.identifier === telemetry?.edata?.item?.sectionId + )?.name; + + if (trackData?.find((e: any) => e?.item?.id === edata?.item?.id)) { + const filterData = trackData?.filter( + (e: any) => e?.item?.id !== edata?.item?.id + ); + trackData = [ + ...filterData, + { + ...edata, + sectionName, + }, + ]; + } else { + trackData = [ + ...(trackData || []), + { + ...edata, + sectionName, + }, + ]; + } + trackData = { score: edata?.score, trackData }; + } else if ( + telemetry?.eid === "START" && + data.mimeType === "video/x-youtube" + ) { + const edata = telemetry?.edata; + trackData = { ...trackData, edata }; + } else if ( + telemetry?.eid === "INTERACT" && + data.mimeType === "video/x-youtube" + ) { + const edata = telemetry?.edata; + trackData = { ...trackData, edata }; + } else if ( + telemetry?.eid === "END" && + telemetry?.edata?.summary.find( + (e: any) => e.endpageseen && e.endpageseen === true + )?.endpageseen + ) { + const summaryData = telemetry?.edata; + if (summaryData?.summary && Array.isArray(summaryData?.summary)) { + const score = summaryData.summary.find((e: any) => "score" in e); + if (score?.score || score?.score == 0) { + trackData = { + score: score?.score, + trackData: telemetry?.edata, + type: "assessmet", + }; + } else { + trackData = telemetry?.edata; + } + } else { + trackData = telemetry?.edata; + } + } else if ( + telemetry?.eid === "IMPRESSION" && + telemetry?.edata?.pageid === "summary_stage_id" + ) { + trackData = trackData; + } else if (["INTERACT", "HEARTBEAT"].includes(telemetry?.eid || "")) { + if ( + telemetry?.edata?.id === "exit" || + telemetry?.edata?.iframeId === "EXIT" + ) { + // Handle exit event if needed + trackData = { type: "exit", score: 0, trackData }; + } + } + + return getTrackData({ + ...trackData, + mimeType: data.mimeType, + iframeId: telemetry?.iframeId, + }); +}; + +export const updateCdataTag = (data: any[]) => { + const playerContext = getPlayerTelemetryContext(); + return { + ...playerContext, + cdata: [...playerContext.cdata, ...data], + tags: [...playerContext.tags, ...data], + }; +}; + +export const getPlayerTelemetryContext = () => { + const data = [ + { + id: localStorage.getItem("grade"), + type: "grade", + }, + { + id: localStorage.getItem("medium"), + type: "medium", + }, + { + id: localStorage.getItem("board"), + type: "board", + }, + { + id: isMobile() ? "mobile" : "web", + type: "device_type", + }, + { + id: navigator.userAgent, + type: "user_agent", + }, + ]; + return { + sid: getSid(), + uid: localStorage.getItem("id"), + did: localStorage.getItem("did"), // send for ifram data + cdata: data, + tags: data, + pdata: { + // optional + // id: VITE_APP_ID, + // ver: VITE_APP_VER, + // pid: VITE_APP_PID, + }, + }; +}; + +export const customLog = (...data: any) => { + if (localStorage.getItem("log") === "true") { + console.log(...data); + } +}; diff --git a/src/video-reel-component/AssessmentPlayer.tsx b/src/video-reel-component/AssessmentPlayer.tsx new file mode 100644 index 0000000..22ab73d --- /dev/null +++ b/src/video-reel-component/AssessmentPlayer.tsx @@ -0,0 +1,139 @@ +import React, { useCallback } from "react"; +import SunbirdPlayer from "./SunbirdPlayer"; +import { TopIcon } from "../common/TopIcon"; +const TELEMETRYBATCH = 20; + +interface AssessmentPlayerProps { + qml_id: string; + videoEndId: { qml_id?: string }; + contentSource: string; + lessonQml: { mimeType: string }; + heightPerItem: { width: number; height: number }; + setHeightPerItem: React.Dispatch< + React.SetStateAction<{ width: number; height: number }> + >; + isQUMLLoading: boolean; + setIsQUMLLoading: React.Dispatch>; + isVisible: boolean; + refQml: React.RefObject; + updateCdataTag: (tags: any[], context: any) => any; + playerContext: any; + width: number; + height: number; + isLoading?: boolean; + playerLink: string; + telemetryURL: string; + telemetryEndpoint: string; +} + +const AssessmentPlayer: React.FC = ({ + qml_id, + videoEndId, + lessonQml, + heightPerItem, + setHeightPerItem, + isQUMLLoading, + setIsQUMLLoading, + isVisible, + refQml, + updateCdataTag, + playerContext, + contentSource, + width, + height, + isLoading, + playerLink, + telemetryEndpoint, + telemetryURL +}) => { + + const handleTopIconClick = useCallback(() => { + if (heightPerItem?.height === 0) { + setHeightPerItem({ + height: height / 2.5, + width: width - 31, + }); + } else { + setHeightPerItem({ height: 0, width: 0 }); + } + if (!isQUMLLoading) { + setIsQUMLLoading(true); + } + }, [ + height, + width, + heightPerItem, + isQUMLLoading, + setHeightPerItem, + setIsQUMLLoading, + ]); + + return ( + qml_id && ( +
+ + {isQUMLLoading && + (videoEndId?.qml_id === qml_id ? ( + <> + ) : ( + + ))} +
+ ) + ); +}; + +export default React.memo(AssessmentPlayer); diff --git a/src/video-reel-component/ContentPlayer.tsx b/src/video-reel-component/ContentPlayer.tsx new file mode 100644 index 0000000..4047e85 --- /dev/null +++ b/src/video-reel-component/ContentPlayer.tsx @@ -0,0 +1,57 @@ +import React, { useCallback, memo } from "react"; +import SunbirdPlayer from "./SunbirdPlayer"; +import { updateCdataTag } from "../services/utils"; + +interface ContentPlayerProps { + lesson?: { mimeType: string }; + qml_id?: string; + width?: number; + height: number; + contentSource?: string; + playerLink: string; + telemetryURL: string; + telemetryEndpoint: string; +} + +const ContentPlayer: React.FC = ({ + lesson, + qml_id, + width, + height, + contentSource, + playerLink, + telemetryURL, + telemetryEndpoint, +}) => { + const TELEMETRYBATCH = 20; + + return ( + + ); +}; + +export default memo(ContentPlayer); diff --git a/src/video-reel-component/SunbirdPlayer.tsx b/src/video-reel-component/SunbirdPlayer.tsx new file mode 100644 index 0000000..415dcf2 --- /dev/null +++ b/src/video-reel-component/SunbirdPlayer.tsx @@ -0,0 +1,143 @@ +import React, { useRef, useEffect, useState } from "react"; + +interface SunbirdPlayerProps { + forwardedRef?: any; + setTrackData?: (data: any) => void; + width: number; + height: number; + mimeType: string; + children?: React.ReactNode[]; + _vstack?: object; + handleExitButton?: () => void; + style?: React.CSSProperties; + playerContext?: object; + batchsize?: number; + adapter: string; + isAssessment?: boolean; + isLoading?: boolean; + baseUrl?: string; + public_url: string; + telemetryURL:string; + telemetryEndpoint:string +} + +const SunbirdPlayer = ({ + setTrackData, + handleExitButton, + width, + height, + forwardedRef, + adapter, + isAssessment = false, + isLoading, + baseUrl, + public_url, + telemetryURL, + telemetryEndpoint, + ...props +}: SunbirdPlayerProps) => { + const iframeRef = useRef(null); + const [url, setUrl] = React.useState(null); + + const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); + const [loading, setLoading] = useState(true); + const { mimeType } = props; + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth <= 768); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + useEffect(() => { + if (public_url && mimeType) { + let newUrl = null; + if (mimeType === "application/pdf") newUrl = "/pdf"; + else if (["video/mp4", "video/webm"].includes(mimeType)) + newUrl = "/video"; + else if (["application/vnd.sunbird.questionset"].includes(mimeType)) + newUrl = "/quml"; + else if ( + [ + "application/vnd.ekstep.ecml-archive", + "application/vnd.ekstep.html-archive", + "application/vnd.ekstep.content-collection", + "application/vnd.ekstep.h5p-archive", + "video/x-youtube", + ].includes(mimeType) + ) + newUrl = "/content-player"; + + setUrl(newUrl); + } + }, [mimeType, public_url]); + + const handleIFrameLoad = () => { + setLoading(false); + }; + + if (!url || !public_url) { + return ( +
+ Content is not correct +
+ ); + } + if (url) { + return ( +
+