diff --git a/src/app.jsx b/src/app.jsx index fbd9c061e..5a009f016 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -112,6 +112,15 @@ function Page() { /> ); } + // ?page=blog + case "blog": { + return ( + + ); + } default: { // TODO: 404 page return

404

; diff --git a/src/core/adapter/devhub-contract.jsx b/src/core/adapter/devhub-contract.jsx index fbbac46d9..979a747ee 100644 --- a/src/core/adapter/devhub-contract.jsx +++ b/src/core/adapter/devhub-contract.jsx @@ -129,14 +129,14 @@ function getAvailableAddons() { // configurator_widget: // "${REPL_DEVHUB}/widget/devhub.entity.addon.kanban.Configurator", // }, - // { - // id: "blog", - // title: "Blog", - // description: "Create a blog for your community", - // view_widget: "${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Viewer", - // configurator_widget: - // "${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Configurator", - // }, + { + id: "blog", + title: "Blog", + description: "Create a blog for your community", + view_widget: "${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Viewer", + configurator_widget: + "${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Configurator", + }, ]; // return Near.view("${REPL_DEVHUB_CONTRACT}", "get_available_addons") ?? null; } diff --git a/src/devhub/components/molecule/Input.jsx b/src/devhub/components/molecule/Input.jsx index 69b27c7e2..79c9d34c0 100644 --- a/src/devhub/components/molecule/Input.jsx +++ b/src/devhub/components/molecule/Input.jsx @@ -14,7 +14,12 @@ const TextInput = ({ ...otherProps }) => { const typeAttribute = - type === "text" || type === "password" || type === "number" ? type : "text"; + type === "text" || + type === "password" || + type === "number" || + type === "date" + ? type + : "text"; const renderedLabels = [ (label?.length ?? 0) > 0 ? ( diff --git a/src/devhub/entity/addon/blog/Card.jsx b/src/devhub/entity/addon/blog/Card.jsx new file mode 100644 index 000000000..b5256f8d5 --- /dev/null +++ b/src/devhub/entity/addon/blog/Card.jsx @@ -0,0 +1,158 @@ +const cidToURL = (cid) => `https://ipfs.near.social/ipfs/${cid}`; + +const Container = styled.div` + width: 100%; + height: 100%; + padding: 24px; + background: #fffefe; + border-radius: 16px; + overflow: hidden; + border: 1px rgba(129, 129, 129, 0.3) solid; + display: inline-flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 24px; +`; + +const InfoContainer = styled.div` + padding-right: 16px; + display: inline-flex; + justify-content: flex-start; + align-items: center; + gap: 16px; +`; + +const InfoText = styled.div` + color: ${(props) => props.color || "#818181"}; + font-size: 16px; + font-family: ${(props) => props.fontFamily || "Aeonik Fono"}; + font-weight: ${(props) => props.fontWeight || "400"}; + line-height: 20px; + word-wrap: break-word; +`; + +const TitleContainer = styled.div` + width: 344px; + padding-right: 16px; + display: inline-flex; + justify-content: flex-start; + align-items: center; + gap: 8px; +`; + +const Title = styled.div` + width: 422px; + color: #151515; + font-size: 36px; + font-family: "Aeonik"; + font-weight: 700; + line-height: 39.6px; + word-wrap: break-word; +`; + +const DescriptionContainer = styled.div` + align-self: stretch; + height: 155px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +`; + +const Description = styled.div` + align-self: stretch; + height: 103px; + padding-bottom: 16px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + gap: 16px; +`; + +const DescriptionText = styled.div` + align-self: stretch; + color: #151515; + font-size: 24px; + font-family: "Aeonik"; + font-weight: 400; + line-height: 28.8px; + word-wrap: break-word; +`; + +const TagsContainer = styled.div` + padding: 16px; + border-radius: 360px; + overflow: hidden; + display: inline-flex; + justify-content: flex-start; + align-items: center; + gap: 16px; +`; + +const Separator = styled.div` + color: #8a8e93; + font-size: 16px; + font-family: "Circular Std"; + font-weight: 400; + line-height: 19.2px; + word-wrap: break-word; +`; + +function Card({ labels, data }) { + const { + title, + subtitle, + description, + category, + author, + image, + community, + date, + } = data; + + function formatDate(date) { + const options = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }; + return date.toLocaleString("en-US", options).replace(",", ""); + } + + return ( + + + + {category && category.toUpperCase()} + + · + {date && formatDate(date)} + + + {title} + + + + {description} + + + {(labels || []).map((label, index) => ( +
+ {index > 0 && } + {label} +
+ ))} +
+
+
+ ); +} + +return { Card }; diff --git a/src/devhub/entity/addon/blog/Configurator.jsx b/src/devhub/entity/addon/blog/Configurator.jsx new file mode 100644 index 000000000..2fef06f06 --- /dev/null +++ b/src/devhub/entity/addon/blog/Configurator.jsx @@ -0,0 +1,280 @@ +const { data, handle, onSubmit } = props; + +const { Tile } = + VM.require("${REPL_DEVHUB}/widget/devhub.components.molecule.Tile") || + (() => <>); + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding: 20px; +`; + +const Item = styled.div` + padding: 10px; + margin: 5px; + display: flex; + align-items: center; + flex-direction: row; + gap: 10px; +`; + +const EditableField = styled.input` + flex: 1; +`; + +const initialData = data; +const [includeLabels, setIncludeLabels] = useState( + initialData.includeLabels || [] +); +const [excludeLabels, setExcludeLabels] = useState( + initialData.excludeLabels || [] +); +const [newIncludeLabel, setNewIncludeLabel] = useState(""); +const [newExcludeLabel, setNewExcludeLabel] = useState(""); +const [layout, setLayout] = useState(initialData.layout || "feed"); // "feed" or "grid" + +const handleAddIncludeLabel = () => { + if (newIncludeLabel) { + setIncludeLabels([...includeLabels, newIncludeLabel]); + setNewIncludeLabel(""); + } +}; + +const handleDeleteIncludeLabel = (index) => { + const updatedIncludeLabels = [...includeLabels]; + updatedIncludeLabels.splice(index, 1); + setIncludeLabels(updatedIncludeLabels); +}; + +const handleAddExcludeLabel = () => { + if (newExcludeLabel) { + setExcludeLabels([...excludeLabels, newExcludeLabel]); + setNewExcludeLabel(""); + } +}; + +const handleDeleteExcludeLabel = (index) => { + const updatedExcludeLabels = [...excludeLabels]; + updatedExcludeLabels.splice(index, 1); + setExcludeLabels(updatedExcludeLabels); +}; + +const hasDataChanged = () => { + return ( + JSON.stringify(includeLabels) !== + JSON.stringify(initialData.includeLabels) || + JSON.stringify(excludeLabels) !== + JSON.stringify(initialData.excludeLabels) || + layout !== initialData.layout + ); +}; + +const handleSubmit = () => { + onSubmit({ includeLabels, excludeLabels, layout }); +}; + +return ( + + + +
+ {/* Settings Tab */} +
+ +

Include Labels

+ {includeLabels.map((item, index) => ( + +
+ +
+ +
+ ))} + +
+ setNewIncludeLabel(e.target.value), + value: newIncludeLabel, + placeholder: "label", + }} + /> +
+ +
+

Exclude Labels

+ {excludeLabels.map((item, index) => ( + +
+ +
+ +
+ ))} + +
+ setNewExcludeLabel(e.target.value), + value: newExcludeLabel, + placeholder: "label", + }} + /> +
+ +
+

Layout

+
+ setLayout(value), + }, + }} + /> +
+
+ +
+
+
+ + {/* Editor Tab */} +
+ + + +
+
+
+); diff --git a/src/devhub/entity/addon/blog/Creator.jsx b/src/devhub/entity/addon/blog/Creator.jsx new file mode 100644 index 000000000..417fb3146 --- /dev/null +++ b/src/devhub/entity/addon/blog/Creator.jsx @@ -0,0 +1,358 @@ +const { Card } = + VM.require("${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Card") || + (() => <>); +const { Page } = + VM.require("${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Page") || + (() => <>); + +const categories = [ + { + label: "Guide", + value: "guide", + }, + { + label: "News", + value: "news", + }, +]; + +const Banner = styled.div` + border-radius: var(--bs-border-radius-xl) !important; + height: 100%; + + & > div :not(.btn) { + position: absolute; + display: none; + margin: 0 !important; + width: 0 !important; + height: 0 !important; + } + + .btn { + padding: 0.5rem 0.75rem !important; + min-height: 32; + line-height: 1; + + border: none; + border-radius: 50px; + --bs-btn-color: #ffffff; + --bs-btn-bg: #087990; + --bs-btn-border-color: #087990; + --bs-btn-hover-color: #ffffff; + --bs-btn-hover-bg: #055160; + --bs-btn-hover-border-color: #055160; + --bs-btn-focus-shadow-rgb: 49, 132, 253; + --bs-btn-active-color: #ffffff; + --bs-btn-active-bg: #055160; + --bs-btn-active-border-color: #055160; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + opacity: 0.8; + + &:hover { + opacity: 1; + } + } +`; + +const FormContainer = styled.div` + & > *:not(:last-child) { + margin-bottom: 1rem; + } +`; + +const { data, handle } = props; + +const initialData = data; // TODO: Check Storage API + +const [content, setContent] = useState(initialData.content || ""); +const [title, setTitle] = useState(initialData.title || ""); +const [subtitle, setSubtitle] = useState(initialData.subtitle || ""); +const [description, setDescription] = useState(initialData.description || ""); +const [author, setAuthor] = useState(initialData.author || ""); +const [previewMode, setPreviewMode] = useState("card"); // "card" or "page" +const [date, setDate] = useState(new Date().toISOString().split("T")[0]); +const [category, setCategory] = useState("guide"); + +// Legacy State.init for IpfsUploader +State.init({ + image: { + cid: "bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4", + }, +}); + +const Container = styled.div` + width: 100%; + margin: 0 auto; + padding: 20px; + text-align: left; +`; + +const cidToURL = (cid) => `https://ipfs.near.social/ipfs/${cid}`; + +const hasDataChanged = () => { + return ( + content !== initialData.content || + title !== initialData.title || + author !== initialData.author + ); +}; + +const handlePublish = () => { + Near.call({ + contractName: "${REPL_DEVHUB_CONTRACT}", + methodName: "add_post", + args: { + labels: ["blog", handle], + body: { + post_type: "Comment", + description: JSON.stringify({ + title, + subtitle, + description, + date, + content, + author, + image: state.image.cid, + tags: data.includeLabels, + community: handle, + }), + comment_version: "V2", + }, + }, + deposit: Big(10).pow(21).mul(2), + gas: Big(10).pow(12).mul(100), + }); +}; + +function Preview() { + switch (previewMode) { + case "card": { + return ( + + ); + } + case "page": { + return ( + + ); + } + } +} + +return ( + +
    +
  • + +
  • +
  • + +
  • +
+
+
+ +
+ + + +
+
+
Title
+
+ setTitle(e.target.value), + value: title, + placeholder: "Title", + }} + /> +
+
+
+
Subtitle
+
+ setSubtitle(e.target.value), + value: subtitle, + placeholder: "Subtitle", + }} + /> +
+
+
+
Category
+
+ ({ + label: it.label, + value: it.value, + })), + }, + ], + rootProps: { + value: category, + placeholder: "Select a category", + onValueChange: (v) => setCategory(v), + }, + }} + /> +
+
+
+
Description
+
+ setDescription(e.target.value), + value: description, + placeholder: "Description", + }} + /> +
+
+
+
Author
+
+ setAuthor(e.target.value), + value: author, + placeholder: "Author", + }} + /> +
+
+
+
Content
+ +
+
+
Date
+ setDate(e.target.value)} + /> +
+
+
+ +
+
+
+
+ setPreviewMode(e.target.value), + options: [ + { label: "Card", value: "card" }, + { label: "Page", value: "page" }, + ], + + title: "Preview mode selection", + }} + /> +
+
+ +
+
+
+
+); diff --git a/src/devhub/entity/addon/blog/Feed.jsx b/src/devhub/entity/addon/blog/Feed.jsx new file mode 100644 index 000000000..32cc96631 --- /dev/null +++ b/src/devhub/entity/addon/blog/Feed.jsx @@ -0,0 +1,151 @@ +// I want this to be passed a query +// renderLoading +// renderItem +// renderError + +// TEMP FOR TESTNET +const { getPostsByLabel } = + VM.require("${REPL_DEVHUB}/widget/core.adapter.devhub-contract") || + (() => {}); + +const { Layout, Item } = props; + +const Container = styled.div``; + +const Loader = styled.div` + text-align: center; + padding: 20px; +`; + +const Notification = styled.p` + text-align: center; + color: #3252a6; +`; + +const QUERYAPI_ENDPOINT = `https://near-queryapi.api.pagoda.co/v1/graphql/`; +const DISPLAY_COUNT = 10; + +const fetchGraphQL = (operationsDoc, operationName, variables) => { + return asyncFetch(QUERYAPI_ENDPOINT, { + method: "POST", + headers: { "x-hasura-role": `bo_near` }, + body: JSON.stringify({ + query: operationsDoc, + variables: variables, + operationName: operationName, + }), + }); +}; + +const queryName = + props.queryName ?? `bo_near_devhub_v17_posts_with_latest_snapshot`; + +const query = `query DevhubPostsQuery($limit: Int = 100, $offset: Int = 0, $where: ${queryName}_bool_exp = {}) { + ${queryName}( + limit: $limit + offset: $offset + order_by: {block_height: desc} + where: $where + ) { + post_id + } + } +`; + +// const [postIds, setPostIds] = useState([]); +const [loading, setLoading] = useState(false); +const [cachedItems, setCachedItems] = useState({}); +const [hasNext, setHasNext] = useState(true); + +const buildWhereClause = () => { + let where = {}; + if (props.author) { + where = { author_id: { _eq: props.author }, ...where }; + } + if (props.term) { + where = { description: { _ilike: `%${props.term}%` }, ...where }; + } + if (props.includeLabels && Array.isArray(props.includeLabels)) { + where = { labels: { _containsAny: props.includeLabels }, ...where }; + } + if (props.excludeLabels && Array.isArray(props.excludeLabels)) { + where = { labels: { _nin: props.excludeLabels }, ...where }; + } + if (!props.recency) { + where = { parent_id: { _is_null: true }, ...where }; + } + return where; +}; + +const fetchPostIds = (offset) => { + if (!offset) { + offset = 0; + } + if (loading) return; + setLoading(true); + const variables = { limit: DISPLAY_COUNT, offset, where: buildWhereClause() }; + const result = fetchGraphQL(query, "DevhubPostsQuery", variables).then( + (result) => { + if (result.status === 200) { + if (result.body.data) { + const data = result.body.data[queryName]; + const newPostIds = data.map((p) => p.post_id); + setPostIds(offset === 0 ? newPostIds : [...postIds, ...newPostIds]); + setHasNext(data.length >= variables.limit); + } else { + console.error("GraphQL Error:", result.errors); + } + setLoading(false); + } + } + ); +}; + +// useEffect(() => { +// fetchPostIds(); +// }, [props.author, props.term, props.tag, props.recency]); + +const handleLoadMore = () => { + if (!hasNext) return; + fetchPostIds(postIds.length); +}; + +const renderLoader = () => Loading...; + +const renderItem = (postId) => ( +
+ {(props.renderItem && props.renderItem(postId)) ||
Post {postId}
} +
+); + +const cachedRenderItem = (postId) => { + if (!(postId in cachedItems)) { + cachedItems[postId] = renderItem(postId); + setCachedItems({ ...cachedItems }); + } + return cachedItems[postId]; +}; + +const postIds = Near.view("${REPL_DEVHUB_CONTRACT}", "get_children_ids"); + +return ( + + {loading && renderLoader()} + {postIds.length > 0 ? ( + + + {/* Layout */} + {postIds.map(cachedRenderItem)} + + + ) : ( +

No posts

+ )} +
+); diff --git a/src/devhub/entity/addon/blog/Page.jsx b/src/devhub/entity/addon/blog/Page.jsx new file mode 100644 index 000000000..8a72077a4 --- /dev/null +++ b/src/devhub/entity/addon/blog/Page.jsx @@ -0,0 +1,67 @@ +const { title, content, author, image, community, tags } = props; + +const cidToURL = (cid) => `https://ipfs.near.social/ipfs/${cid}`; + +function Page({ labels, data }) { + const { + title, + subtitle, + description, + category, + author, + image, + community, + date, + } = data; + + function formatDate(date) { + const options = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }; + return date.toLocaleString("en-US", options).replace(",", ""); + } + + return ( +
+ {image && ( +
+ Blog Header Image +
+ )} +

{title}

+

+ Written by {author || "AUTHOR"} in{" "} + {community || "COMMUNITY"} +

+
+ {(tags || []).map((tag) => ( + + ))} +
+
+ +
+
+ ); +} + +return { Page }; diff --git a/src/devhub/entity/addon/blog/Viewer.jsx b/src/devhub/entity/addon/blog/Viewer.jsx new file mode 100644 index 000000000..f6e050cec --- /dev/null +++ b/src/devhub/entity/addon/blog/Viewer.jsx @@ -0,0 +1,50 @@ +const { Card } = + VM.require("${REPL_DEVHUB}/widget/devhub.entity.addon.blog.Card") || + (() => <>); + +const { href } = VM.require("${REPL_DEVHUB}/widget/core.lib.url") || (() => {}); + +const { includeLabels, excludeLabels, layout } = props; + +const Grid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 16px; + + @media (min-width: 769px) { + grid-template-columns: repeat(2, 1fr); + } +`; + +function BlogCard(postId) { + return ( + + }} // I wonder if this could take list of types, their templates, normalizer functions, etc... and have this all as a module + /> + {/* // so then you could swap between devhub contract or social contract sources, it doesn't matter. */} + + ); +} + +return ( +
+ + layout === "grid" ? {children} : children, + }} + /> +
+); diff --git a/src/devhub/entity/addon/wiki/Configurator.jsx b/src/devhub/entity/addon/wiki/Configurator.jsx index 6ce7ef1a6..272930154 100644 --- a/src/devhub/entity/addon/wiki/Configurator.jsx +++ b/src/devhub/entity/addon/wiki/Configurator.jsx @@ -4,6 +4,7 @@ const initialData = data; const [content, setContent] = useState(data.content || ""); const [title, setTitle] = useState(data.title || ""); const [description, setDescription] = useState(data.description || ""); + const [textAlign, setTextAlign] = useState(data.textAlign || "left"); const Container = styled.div` diff --git a/src/devhub/entity/addon/wiki/Viewer.jsx b/src/devhub/entity/addon/wiki/Viewer.jsx index 75b0ce977..207138fa2 100644 --- a/src/devhub/entity/addon/wiki/Viewer.jsx +++ b/src/devhub/entity/addon/wiki/Viewer.jsx @@ -1,24 +1,3 @@ -const { content, title, description, textAlign } = props; - -const Container = styled.div` - width: 100%; - margin: 0 auto; - text-align: ${(p) => p.textAlign ?? "left"}; -`; - -const Content = styled.div` - margin: 20px 0; - text-align: left; -`; - -const Title = styled.h1` - margin-bottom: 10px; /* Optional: Adjust margin as needed */ -`; - -const Description = styled.p` - margin-bottom: 20px; /* Optional: Adjust margin as needed */ -`; - const CenteredMessage = styled.div` display: flex; flex-direction: column; diff --git a/src/devhub/entity/post/Postv2.jsx b/src/devhub/entity/post/Postv2.jsx new file mode 100644 index 000000000..1940bb489 --- /dev/null +++ b/src/devhub/entity/post/Postv2.jsx @@ -0,0 +1,20 @@ +const { getPost } = + VM.require("${REPL_DEVHUB}/widget/core.adapter.devhub-contract") || + (() => {}); + +const { postKey, template } = props; + +const post = getPost({ post_id: parseInt(postKey) }); + +if (!post) { + return
Loading ...
; +} + +const Template = template || (() => <>); + +return ( +