diff --git a/src/DevHub/App.jsx b/src/DevHub/App.jsx
new file mode 100644
index 000000000..d45979ae4
--- /dev/null
+++ b/src/DevHub/App.jsx
@@ -0,0 +1,116 @@
+/**
+ * This is the main entry point for the DevHub application.
+ * Page route gets passed in through params, along with all other page props.
+ */
+
+const { page, ...passProps } = props;
+
+// Import our modules
+const { AppLayout } = VM.require(
+ "${REPL_DEVHUB}/widget/DevHub.components.templates.AppLayout"
+);
+if (!AppLayout) {
+ return
Loading modules...
;
+}
+
+// Define our Theme
+const Theme = styled.div`
+ a {
+ color: inherit;
+ }
+
+ .attractable {
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
+ transition: box-shadow 0.6s;
+
+ &:hover {
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
+ }
+ }
+`;
+
+if (!page) {
+ // If no page is specified, we default to the home page
+ page = "feed";
+}
+
+// This is our navigation, rendering the page based on the page parameter
+function Page() {
+ const routes = page.split(".");
+ switch (routes[0]) {
+ // ?page=home
+ case "home": {
+ return Homepage
;
+ }
+ // ?page=communities
+ case "communities": {
+ return (
+
+ );
+ }
+ // ?page=community
+ case "community": {
+ return (
+ {
+ switch (routes[1]) {
+ // ?page=community.configuration
+ case "configuration": {
+ return (
+
+ );
+ }
+ }
+ // ?page=community
+ return (
+
+ );
+ },
+ }}
+ />
+ );
+ }
+ // ?page=feed
+ case "feed": {
+ // TODO: This needs to be updated, old widget has the header attached
+ return (
+
+ );
+ }
+ } // default case does not work in VM
+ // If no page is found, we return a 404
+ return 404
;
+}
+
+return (
+
+
+
+
+
+);
diff --git a/src/DevHub/components/atom/Icon.jsx b/src/DevHub/components/atom/Icon.jsx
new file mode 100644
index 000000000..b9e425dfa
--- /dev/null
+++ b/src/DevHub/components/atom/Icon.jsx
@@ -0,0 +1,45 @@
+const svgIconsByVariant = {
+ floppy_drive: (elementProps) => (
+
+
+
+
+
+ ),
+};
+
+const iconsByType = {
+ bootstrap_icon: ({ className, variant, ...otherProps }) => (
+
+ ),
+
+ svg_icon: ({ variant, ...elementProps }) =>
+ svgIconsByVariant[variant](elementProps),
+};
+
+const Icon = ({ type, ...otherProps }) =>
+ typeof iconsByType[type] === "function"
+ ? iconsByType[type](otherProps)
+ : null;
+
+return Icon(props);
diff --git a/src/DevHub/components/atom/Toggle.jsx b/src/DevHub/components/atom/Toggle.jsx
new file mode 100644
index 000000000..f91f10cf0
--- /dev/null
+++ b/src/DevHub/components/atom/Toggle.jsx
@@ -0,0 +1,77 @@
+const ToggleRoot = styled.div`
+ justify-content: space-between;
+ width: fit-content;
+ max-width: 100%;
+`;
+
+const ToggleSwitchRoot = styled("Switch.Root")`
+ all: unset;
+ display: block;
+ width: 42px;
+ height: 25px;
+ background-color: #d1d1d1;
+ border-radius: 9999px;
+ position: relative;
+ box-shadow: 0 2px 10px var(--blackA7);
+
+ &[data-state="checked"] {
+ background-color: #00d084;
+ }
+
+ &[data-disabled=""] {
+ opacity: 0.7;
+ }
+`;
+
+const ToggleSwitchThumb = styled("Switch.Thumb")`
+ all: unset;
+ display: block;
+ width: 21px;
+ height: 21px;
+ border-radius: 9999px;
+ transition: transform 100ms;
+ transform: translateX(2px);
+ will-change: transform;
+
+ &[data-state="checked"] {
+ transform: translateX(19px);
+ }
+`;
+
+const ToggleLabel = styled.label`
+ white-space: nowrap;
+`;
+
+const Toggle = ({
+ className,
+ direction,
+ disabled,
+ inputProps,
+ key,
+ label,
+ onChange,
+ value: checked,
+ ...rest
+}) => (
+
+ {label}
+
+
+ {!disabled && }
+
+
+);
+
+return Toggle(props);
diff --git a/src/DevHub/components/molecule/Button.jsx b/src/DevHub/components/molecule/Button.jsx
new file mode 100644
index 000000000..5999c422d
--- /dev/null
+++ b/src/DevHub/components/molecule/Button.jsx
@@ -0,0 +1,144 @@
+const styles = `
+ padding: 0.5rem 1.2rem !important;
+ min-height: 42px;
+ line-height: 1.5;
+ text-decoration: none !important;
+
+ &:not(.shadow-none) {
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
+ transition: box-shadow 0.6s;
+ }
+
+ &:hover {
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
+ }
+
+ &.btn-sm {
+ padding: 0.5rem 0.8rem !important;
+ min-height: 32px;
+ line-height: 1;
+ }
+
+ &.btn-lg {
+ padding: 1rem 1.5rem !important;
+ min-height: 48px;
+ }
+
+ &.btn-primary {
+ border: none;
+ --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);
+ --bs-btn-disabled-color: #ffffff;
+ --bs-btn-disabled-bg: #0551604a;
+ }
+
+ &.btn-outline-primary {
+ --bs-btn-color: #087990;
+ --bs-btn-border-color: #087990;
+ --bs-btn-hover-color: #ffffff;
+ --bs-btn-hover-bg: #087990;
+ --bs-btn-hover-border-color: #087990;
+ --bs-btn-focus-shadow-rgb: 49, 132, 253;
+ --bs-btn-active-color: #ffffff;
+ --bs-btn-active-bg: #087990;
+ --bs-btn-active-border-color: #087990;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-border-color: #0551604a;
+ }
+
+ &[class*="btn-outline-"] {
+ border-width: 2px;
+ }
+
+ &.btn-outline-primary {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+
+ &.btn-secondary {
+ border: none;
+ }
+
+ &.btn-outline-secondary {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+
+ &.btn-success {
+ border: none;
+ --bs-btn-disabled-bg: #35482a4a;
+ }
+
+ &.btn-outline-success {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+
+ &.btn-danger {
+ border: none;
+ }
+
+ &.btn-outline-danger {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+
+ &.btn-warning {
+ border: none;
+ }
+
+ &.btn-outline-warning {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+
+ &.btn-info {
+ border: none;
+ }
+
+ &.btn-outline-info {
+ --bs-btn-disabled-color: #6c757d8f;
+ }
+`;
+
+const rootElementByType = (type) =>
+ type === "link"
+ ? styled.a`
+ ${styles}
+ `
+ : styled.button`
+ ${styles}
+ `;
+
+const Button = ({ classNames, icon: iconProps, label, type, ...restProps }) => {
+ const ButtonRoot = rootElementByType(type);
+
+ return (
+
+ {iconProps !== null &&
+ typeof iconProps === "object" &&
+ !Array.isArray(iconProps) && (
+
+ )}
+
+ {label}
+
+
+ );
+};
+
+return Button(props);
diff --git a/src/DevHub/components/molecule/MarkdownEditor.jsx b/src/DevHub/components/molecule/MarkdownEditor.jsx
new file mode 100644
index 000000000..745f1f997
--- /dev/null
+++ b/src/DevHub/components/molecule/MarkdownEditor.jsx
@@ -0,0 +1,27 @@
+const MarkdownEditor = ({ data, onChange }) => {
+ return (
+
+ );
+};
+
+return MarkdownEditor(props);
diff --git a/src/DevHub/components/molecule/MarkdownViewer.jsx b/src/DevHub/components/molecule/MarkdownViewer.jsx
new file mode 100644
index 000000000..1f3bc716c
--- /dev/null
+++ b/src/DevHub/components/molecule/MarkdownViewer.jsx
@@ -0,0 +1,56 @@
+const Wrapper = styled.div`
+ p {
+ white-space: pre-line; // This ensures text breaks to new line
+
+ span {
+ white-space: normal; // and this ensures profile links look normal
+ }
+ }
+
+ blockquote {
+ margin: 1em 0;
+ padding-left: 1.5em;
+ border-left: 4px solid #ccc;
+ color: #666;
+ font-style: italic;
+ font-size: inherit;
+ }
+
+ pre {
+ background-color: #f4f4f4;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 1em;
+ overflow-x: auto;
+ font-family: "Courier New", Courier, monospace;
+ }
+`;
+
+const Embedded = styled.span`
+ white-space: normal;
+
+ p {
+ white-space: normal;
+ }
+`;
+
+const renderMention =
+ props.renderMention ??
+ ((accountId) => (
+
+
+
+ ));
+
+return (
+
+
+
+);
diff --git a/src/DevHub/components/molecule/PostControls.jsx b/src/DevHub/components/molecule/PostControls.jsx
new file mode 100644
index 000000000..a252c37e8
--- /dev/null
+++ b/src/DevHub/components/molecule/PostControls.jsx
@@ -0,0 +1,28 @@
+const { className, title, icon, href, onClick } = props;
+
+const buttonStyle = {
+ backgroundColor: "#0C7283",
+ color: "#f3f3f3",
+};
+
+return (
+
+);
diff --git a/src/DevHub/components/molecule/Tile.jsx b/src/DevHub/components/molecule/Tile.jsx
new file mode 100644
index 000000000..265ca2811
--- /dev/null
+++ b/src/DevHub/components/molecule/Tile.jsx
@@ -0,0 +1,19 @@
+// TODO: Convert into a module
+const Tile = ({ id, children, className, minHeight, style }) => (
+
+ {children}
+
+);
+
+return Tile(props);
diff --git a/src/DevHub/components/organism/Configurator.jsx b/src/DevHub/components/organism/Configurator.jsx
new file mode 100644
index 000000000..dfcb788c6
--- /dev/null
+++ b/src/DevHub/components/organism/Configurator.jsx
@@ -0,0 +1,404 @@
+function href(widgetName, linkProps) {
+ const linkPropsQuery = Object.entries(linkProps)
+ .filter(([_key, nullable]) => (nullable ?? null) !== null)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+
+ return `/#/${REPL_DEVHUB}/widget/gigs-board.pages.${widgetName}${
+ linkPropsQuery ? "?" : ""
+ }${linkPropsQuery}`;
+}
+/* INCLUDE: "core/lib/struct" */
+const Struct = {
+ deepFieldUpdate: (
+ node,
+ { input, params, path: [nextNodeKey, ...remainingPath], via: toFieldValue }
+ ) => ({
+ ...node,
+
+ [nextNodeKey]:
+ remainingPath.length > 0
+ ? Struct.deepFieldUpdate(
+ Struct.typeMatch(node[nextNodeKey]) ||
+ Array.isArray(node[nextNodeKey])
+ ? node[nextNodeKey]
+ : {
+ ...((node[nextNodeKey] ?? null) !== null
+ ? { __archivedLeaf__: node[nextNodeKey] }
+ : {}),
+ },
+
+ { input, path: remainingPath, via: toFieldValue }
+ )
+ : toFieldValue({
+ input,
+ lastKnownValue: node[nextNodeKey],
+ params,
+ }),
+ }),
+
+ isEqual: (input1, input2) =>
+ Struct.typeMatch(input1) && Struct.typeMatch(input2)
+ ? JSON.stringify(Struct.toOrdered(input1)) ===
+ JSON.stringify(Struct.toOrdered(input2))
+ : false,
+
+ toOrdered: (input) =>
+ Object.keys(input)
+ .sort()
+ .reduce((output, key) => ({ ...output, [key]: input[key] }), {}),
+
+ pick: (object, subsetKeys) =>
+ Object.fromEntries(
+ Object.entries(object ?? {}).filter(([key, _]) =>
+ subsetKeys.includes(key)
+ )
+ ),
+
+ typeMatch: (input) =>
+ input !== null && typeof input === "object" && !Array.isArray(input),
+};
+/* END_INCLUDE: "core/lib/struct" */
+/* INCLUDE: "core/lib/gui/form" */
+const defaultFieldUpdate = ({
+ input,
+ lastKnownValue,
+ params: { arrayDelimiter },
+}) => {
+ switch (typeof input) {
+ case "boolean":
+ return input;
+
+ case "object": {
+ if (Array.isArray(input) && typeof lastKnownValue === "string") {
+ return input.join(arrayDelimiter ?? ",");
+ } else {
+ return Array.isArray(lastKnownValue)
+ ? [...lastKnownValue, ...input]
+ : { ...lastKnownValue, ...input };
+ }
+ }
+
+ case "string":
+ return Array.isArray(lastKnownValue)
+ ? input.split(arrayDelimiter ?? ",").map((string) => string.trim())
+ : input;
+
+ default: {
+ if ((input ?? null) === null) {
+ switch (typeof lastKnownValue) {
+ case "boolean":
+ return !lastKnownValue;
+
+ default:
+ return lastKnownValue;
+ }
+ } else return input;
+ }
+ }
+};
+
+const useForm = ({ initialValues, onUpdate, stateKey, uninitialized }) => {
+ const initialFormState = {
+ hasUnsubmittedChanges: false,
+ values: initialValues ?? {},
+ };
+
+ const formState = state[stateKey] ?? null,
+ isSynced = Struct.isEqual(formState?.values ?? {}, initialFormState.values);
+
+ const formReset = () =>
+ State.update((lastKnownComponentState) => ({
+ ...lastKnownComponentState,
+ [stateKey]: initialFormState,
+ hasUnsubmittedChanges: false,
+ }));
+
+ const formUpdate =
+ ({ path, via: customFieldUpdate, ...params }) =>
+ (fieldInput) => {
+ const updatedValues = Struct.deepFieldUpdate(
+ formState?.values ?? {},
+
+ {
+ input: fieldInput?.target?.value ?? fieldInput,
+ params,
+ path,
+
+ via:
+ typeof customFieldUpdate === "function"
+ ? customFieldUpdate
+ : defaultFieldUpdate,
+ }
+ );
+
+ State.update((lastKnownComponentState) => ({
+ ...lastKnownComponentState,
+
+ [stateKey]: {
+ hasUnsubmittedChanges: !Struct.isEqual(
+ updatedValues,
+ initialFormState.values
+ ),
+
+ values: updatedValues,
+ },
+ }));
+
+ if (
+ typeof onUpdate === "function" &&
+ !Struct.isEqual(updatedValues, initialFormState.values)
+ ) {
+ onUpdate(updatedValues);
+ }
+ };
+
+ if (
+ !uninitialized &&
+ (formState === null || (!formState.hasUnsubmittedChanges && !isSynced))
+ ) {
+ formReset();
+ }
+
+ return {
+ ...(formState ?? initialFormState),
+ isSynced,
+ reset: formReset,
+ stateKey,
+ update: formUpdate,
+ };
+};
+/* END_INCLUDE: "core/lib/gui/form" */
+
+const ValueView = styled.div`
+ & > p {
+ margin: 0;
+ }
+`;
+
+const fieldParamsByType = {
+ array: {
+ name: "components.molecule.text-input",
+ inputProps: { type: "text" },
+ },
+
+ boolean: {
+ name: "components.atom.toggle",
+ },
+
+ string: {
+ name: "components.molecule.text-input",
+ inputProps: { type: "text" },
+ },
+};
+
+const defaultFieldsRender = ({ schema, form, isEditable }) => (
+ <>
+ {Object.entries(schema).map(
+ (
+ [key, { format, inputProps, noop, label, order, style, ...fieldProps }],
+ idx
+ ) => {
+ const fieldKey = `${idx}-${key}`,
+ fieldValue = form.values[key];
+
+ const fieldType = Array.isArray(fieldValue)
+ ? "array"
+ : typeof (fieldValue ?? "");
+
+ const isDisabled = noop ?? inputProps.disabled ?? false;
+
+ const viewClassName = [
+ (fieldValue?.length ?? 0) > 0 ? "" : "text-muted",
+ "m-0",
+ ].join(" ");
+
+ return (
+ <>
+
+ {label}
+
+
+ {format !== "markdown" ? (
+
+ {(fieldType === "array" && format === "comma-separated"
+ ? fieldValue
+ .filter((string) => string.length > 0)
+ .join(", ")
+ : fieldValue
+ )?.toString?.() || "none"}
+
+ ) : (fieldValue?.length ?? 0) > 0 ? (
+
+ ) : (
+ none
+ )}
+
+
+
+ >
+ );
+ }
+ )}
+ >
+);
+
+const Configurator = ({
+ actionsAdditional,
+ cancelLabel,
+ classNames,
+ externalState,
+ fieldsRender: customFieldsRender,
+ formatter: toFormatted,
+ isEmbedded,
+ isValid,
+ onCancel,
+ onChange,
+ onSubmit,
+ schema,
+ submitIcon,
+ submitLabel,
+ ...otherProps
+}) => {
+ const fieldsRender =
+ typeof customFieldsRender === "function"
+ ? customFieldsRender
+ : defaultFieldsRender;
+
+ State.init({
+ isActive: otherProps.isActive ?? false,
+ });
+
+ const isActive = otherProps.isActive ?? state.isActive;
+
+ const initialValues = Struct.typeMatch(schema)
+ ? Struct.pick(externalState ?? {}, Object.keys(schema))
+ : {};
+
+ const form = useForm({ initialValues, onUpdate: onChange, stateKey: "form" });
+
+ const formFormattedValues =
+ typeof toFormatted === "function" ? toFormatted(form.values) : form.values;
+
+ const isFormValid =
+ typeof isValid === "function" ? isValid(formFormattedValues) : true;
+
+ const formToggle = (forcedState) =>
+ State.update((lastKnownState) => ({
+ ...lastKnownState,
+ isActive: forcedState ?? !lastKnownState.isActive,
+ }));
+
+ const onCancelClick = () => {
+ if (!isActive) formToggle(false);
+ form.reset();
+ if (isEmbedded && typeof onSubmit === "function") onSubmit(initialValues);
+ if (typeof onCancel === "function") onCancel();
+ };
+
+ const onSubmitClick = () => {
+ if (typeof onSubmit === "function" && isFormValid) {
+ onSubmit(formFormattedValues);
+ }
+
+ formToggle(false);
+ };
+
+ return (
+
+
+ {fieldsRender({
+ form,
+ isEditable: isActive,
+ schema,
+ })}
+
+
+
+ {actionsAdditional ? (
+
{actionsAdditional}
+ ) : null}
+
+
+
+
+
+ );
+};
+
+return Configurator(props);
diff --git a/src/DevHub/components/templates/AppLayout.jsx b/src/DevHub/components/templates/AppLayout.jsx
new file mode 100644
index 000000000..091887670
--- /dev/null
+++ b/src/DevHub/components/templates/AppLayout.jsx
@@ -0,0 +1,120 @@
+const StyledHeader = styled.div`
+ height: 62px;
+ background: #181818;
+ margin-top: -25px; // There is a gap on both near.social and near.org
+ padding: 16px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const Logo = styled.img`
+ height: 30px;
+`;
+
+const HeaderActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ color: white;
+`;
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-height: 100vh;
+`;
+
+const ContentContainer = styled.div`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+`;
+
+function QuestionButton() {
+ return (
+
+ );
+}
+
+const AppHeader = ({ page }) => {
+ return (
+
+
+
+
+
+
+ {page !== "communities" && (
+
+ Communities
+
+ )}
+
+
+ Developer DAO
+
+
+
+
+ );
+};
+
+function AppLayout({ page, children }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+return { AppLayout };
diff --git a/src/DevHub/entity/addon/wiki/Configurator.jsx b/src/DevHub/entity/addon/wiki/Configurator.jsx
new file mode 100644
index 000000000..85357c646
--- /dev/null
+++ b/src/DevHub/entity/addon/wiki/Configurator.jsx
@@ -0,0 +1,19 @@
+const { data, onChange } = props;
+
+const [content, setContent] = useState(data.content);
+
+const Container = styled.div`
+ width: 80%;
+ margin: 0 auto;
+ padding: 20px;
+ text-align: center;
+`;
+
+return (
+
+
+
+);
diff --git a/src/DevHub/entity/addon/wiki/Viewer.jsx b/src/DevHub/entity/addon/wiki/Viewer.jsx
new file mode 100644
index 000000000..af34e562d
--- /dev/null
+++ b/src/DevHub/entity/addon/wiki/Viewer.jsx
@@ -0,0 +1,37 @@
+const { title, description, content } = props;
+
+const Container = styled.div`
+ width: 80%;
+ margin: 0 auto;
+ padding: 20px;
+ text-align: center;
+`;
+
+const Title = styled.h1`
+ font-size: 24px;
+ margin: 10px 0;
+`;
+
+const Description = styled.p`
+ font-size: 16px;
+ color: #555;
+ margin: 10px 0;
+`;
+
+const Content = styled.div`
+ margin: 20px 0;
+ text-align: left;
+`;
+
+return (
+
+ {title}
+ {description}
+
+
+
+
+);
diff --git a/src/DevHub/entity/community/AboutConfigurator.jsx b/src/DevHub/entity/community/AboutConfigurator.jsx
new file mode 100644
index 000000000..c888276db
--- /dev/null
+++ b/src/DevHub/entity/community/AboutConfigurator.jsx
@@ -0,0 +1,64 @@
+const CommunityAboutSchema = {
+ bio_markdown: {
+ format: "markdown",
+
+ inputProps: {
+ min: 3,
+ max: 200,
+
+ placeholder:
+ "Tell people about your community. This will appear on your communityβs homepage.",
+
+ resize: "none",
+ },
+
+ label: "Bio",
+ multiline: true,
+ order: 1,
+ },
+
+ twitter_handle: {
+ inputProps: { prefix: "https://twitter.com/", min: 2, max: 60 },
+ label: "Twitter",
+ order: 2,
+ },
+
+ github_handle: {
+ inputProps: { prefix: "https://github.com/", min: 2, max: 60 },
+ label: "Github",
+ order: 3,
+ },
+
+ telegram_handle: {
+ inputProps: { prefix: "https://t.me/", min: 2, max: 60 },
+ format: "comma-separated",
+ label: "Telegram",
+ order: 4,
+ },
+
+ website_url: {
+ inputProps: { prefix: "https://", min: 2, max: 60 },
+ label: "Website",
+ order: 5,
+ },
+};
+
+const { data, onSubmit, onCancel, setIsActive, isActive } = props;
+
+function handleOnSubmit(v) {
+ onSubmit(v);
+ setIsActive(false);
+}
+
+return (
+
+);
diff --git a/src/DevHub/entity/community/AccessControlConfigurator.jsx b/src/DevHub/entity/community/AccessControlConfigurator.jsx
new file mode 100644
index 000000000..bf7c02a3e
--- /dev/null
+++ b/src/DevHub/entity/community/AccessControlConfigurator.jsx
@@ -0,0 +1,34 @@
+const CommunityAccessControlSchema = {
+ admins: {
+ format: "comma-separated",
+ inputProps: { required: true },
+ label: "Admins",
+ order: 1,
+ },
+};
+
+const communityAccessControlFormatter = ({ admins, ...otherFields }) => ({
+ ...otherFields,
+ admins: admins.filter((string) => string.length > 0),
+});
+
+const { data, onSubmit, onCancel, setIsActive, isActive } = props;
+
+function handleOnSubmit(v) {
+ onSubmit(v);
+ setIsActive(false);
+}
+
+return (
+
+);
diff --git a/src/DevHub/entity/community/Activity.jsx b/src/DevHub/entity/community/Activity.jsx
new file mode 100644
index 000000000..29e04abcb
--- /dev/null
+++ b/src/DevHub/entity/community/Activity.jsx
@@ -0,0 +1,48 @@
+const { handle } = props;
+
+const { getCommunity } = VM.require(
+ "${REPL_DEVHUB}/widget/DevHub.modules.contract-sdk"
+);
+
+const communityData = getCommunity({ handle });
+
+if (communityData === null) {
+ return Loading...
;
+}
+
+return (
+
+
+
+
+ Required tags:
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/DevHub/entity/community/AddonConfigurator.jsx b/src/DevHub/entity/community/AddonConfigurator.jsx
new file mode 100644
index 000000000..e4173615f
--- /dev/null
+++ b/src/DevHub/entity/community/AddonConfigurator.jsx
@@ -0,0 +1,41 @@
+const { addon, config, onSubmit } = props;
+
+const [name, setName] = useState(config.name || "");
+const [enabled, setEnabled] = useState(config.enabled || true);
+
+function handleOnSubmit(v) {
+ onSubmit({
+ ...config,
+ name,
+ enabled,
+ parameters: JSON.stringify(v),
+ });
+}
+
+return (
+
+ {/* TODO: Replace with Input from library */}
+ setName(e.target.value)}
+ />
+
+
+
+);
diff --git a/src/DevHub/entity/community/Addons.jsx b/src/DevHub/entity/community/Addons.jsx
new file mode 100644
index 000000000..42f430c57
--- /dev/null
+++ b/src/DevHub/entity/community/Addons.jsx
@@ -0,0 +1,131 @@
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+`;
+
+const Item = styled.div`
+ padding: 10px;
+ margin: 5px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+`;
+
+const Icon = styled.span`
+ margin-right: 10px;
+`;
+
+const EditableField = styled.input`
+ flex: 1;
+`;
+
+const ToggleButton = styled.input`
+ margin-left: 10px;
+`;
+
+const Editor = ({ data, onUpdate, onMove, index, isTop, isBottom }) => {
+ const handleNameChange = (event) => {
+ const newName = event.target.value;
+ onUpdate({ ...data, display_name: newName });
+ };
+
+ const handleIconChange = (event) => {
+ const newIcon = event.target.value;
+ onUpdate({ ...data, icon: newIcon });
+ };
+
+ const handleEnableChange = () => {
+ onUpdate({ ...data, enabled: !data.enabled });
+ };
+
+ const moveItemUp = () => {
+ if (!isTop) {
+ onMove(index, index - 1);
+ }
+ };
+
+ const moveItemDown = () => {
+ if (!isBottom) {
+ onMove(index, index + 1);
+ }
+ };
+
+ return (
+
+ );
+};
+
+const AddonsConfigurator = ({ items }) => {
+ const [list, setList] = useState(items);
+
+ const updateItem = (updatedItem) => {
+ const updatedList = list.map((item) =>
+ item.id === updatedItem.id ? updatedItem : item
+ );
+ setList(updatedList);
+ };
+
+ const moveItem = (fromIndex, toIndex) => {
+ const updatedList = [...list];
+ const [movedItem] = updatedList.splice(fromIndex, 1);
+ updatedList.splice(toIndex, 0, movedItem);
+ setList(updatedList);
+ };
+
+ return (
+
+ {list.map((item, index) => (
+
+ ))}
+
+ );
+};
+
+return AddonsConfigurator(props);
diff --git a/src/DevHub/entity/community/Blog.jsx b/src/DevHub/entity/community/Blog.jsx
new file mode 100644
index 000000000..54ac58372
--- /dev/null
+++ b/src/DevHub/entity/community/Blog.jsx
@@ -0,0 +1,32 @@
+const [author, setAuthor] = useState(props.author || "");
+const [tag, setTag] = useState(props.tag || "");
+
+const onTagSearch = (tag) => {
+ setTag(tag);
+};
+
+const onAuthorSearch = (author) => {
+ setAuthor(author);
+};
+
+return (
+
+ ),
+ onAuthorSearch,
+ onTagSearch,
+ recency,
+ tag: tag,
+ tagQuery: { tag },
+ transactionHashes: props.transactionHashes,
+ }}
+ />
+);
diff --git a/src/DevHub/entity/community/BrandingConfigurator.jsx b/src/DevHub/entity/community/BrandingConfigurator.jsx
new file mode 100644
index 000000000..d06d99b3f
--- /dev/null
+++ b/src/DevHub/entity/community/BrandingConfigurator.jsx
@@ -0,0 +1,165 @@
+const Banner = styled.div`
+ border-top-left-radius: var(--bs-border-radius-xl) !important;
+ border-top-right-radius: var(--bs-border-radius-xl) !important;
+ height: calc(100% - 100px);
+
+ & > 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 Logo = styled.div`
+ & > 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 cidToURL = (cid) => `https://ipfs.near.social/ipfs/${cid}`;
+
+const { data, onSubmit, hasConfigurePermissions, link } = props;
+
+const initialInput = { banner: null, logo: null };
+
+const initialValues = {
+ banner: { cid: data.banner_url.split("/").at(-1) },
+ logo: { cid: data.logo_url.split("/").at(-1) },
+};
+
+State.init({
+ input: initialInput,
+});
+
+const hasUnsubmittedChanges = Object.values(state.input).some(
+ (value) => value !== null
+);
+
+const isSynced = state.input === initialValues;
+
+if (hasUnsubmittedChanges && !isSynced) {
+ onSubmit({
+ banner_url: cidToURL(state.input.banner?.cid ?? initialValues.banner.cid),
+ logo_url: cidToURL(state.input.logo?.cid ?? initialValues.logo.cid),
+ });
+
+ State.update((lastKnownState) => ({
+ ...lastKnownState,
+ input: initialInput,
+ }));
+}
+
+return (
+
+
+ {hasConfigurePermissions && (
+
+ )}
+
+
+ {hasConfigurePermissions && }
+
+
+
+
+ {typeof link === "string" && link.length > 0 ? (
+ {data.name}
+ ) : (
+ data.name
+ )}
+
+
+
+ {data.description}
+
+
+
+);
diff --git a/src/DevHub/entity/community/ConfigurationSection.jsx b/src/DevHub/entity/community/ConfigurationSection.jsx
new file mode 100644
index 000000000..d648977a0
--- /dev/null
+++ b/src/DevHub/entity/community/ConfigurationSection.jsx
@@ -0,0 +1,49 @@
+const {
+ title,
+ hasConfigurePermissions,
+ Configurator,
+ Preview,
+ headerRight,
+ forceEditActive,
+} = props;
+
+const [isEditActive, setEditActive] = useState(forceEditActive || false);
+
+function SectionHeader() {
+ return (
+
+
+ {title}
+
+ {headerRight ||
+ (hasConfigurePermissions && (
+ setEditActive(!isEditActive),
+ }}
+ />
+ ))}
+
+ );
+}
+
+return (
+
+
+ setEditActive(!isEditActive)}
+ />
+
+);
diff --git a/src/DevHub/entity/community/InformationConfigurator.jsx b/src/DevHub/entity/community/InformationConfigurator.jsx
new file mode 100644
index 000000000..1510ee129
--- /dev/null
+++ b/src/DevHub/entity/community/InformationConfigurator.jsx
@@ -0,0 +1,78 @@
+const CommunityInformationSchema = {
+ name: {
+ inputProps: {
+ min: 2,
+ max: 30,
+ placeholder: "Community name.",
+ required: true,
+ },
+
+ label: "Name",
+ order: 1,
+ },
+
+ description: {
+ inputProps: {
+ min: 2,
+ max: 60,
+
+ placeholder:
+ "Describe your community in one short sentence that will appear in the communities discovery page.",
+
+ required: true,
+ },
+
+ label: "Description",
+ order: 2,
+ },
+
+ handle: {
+ inputProps: {
+ min: 2,
+ max: 40,
+
+ placeholder:
+ "Choose unique URL handle for your community. Example: zero-knowledge.",
+
+ required: true,
+ },
+
+ label: "URL handle",
+ order: 3,
+ },
+
+ tag: {
+ inputProps: {
+ min: 2,
+ max: 30,
+
+ placeholder:
+ "Any posts with this tag will show up in your community feed.",
+
+ required: true,
+ },
+
+ label: "Tag",
+ order: 4,
+ },
+};
+
+const { data, onSubmit, onCancel, setIsActive, isActive } = props;
+
+function handleOnSubmit(v) {
+ onSubmit(v);
+ setIsActive(false);
+}
+
+return (
+
+);
diff --git a/src/DevHub/entity/community/NewAddon.jsx b/src/DevHub/entity/community/NewAddon.jsx
new file mode 100644
index 000000000..299232bf1
--- /dev/null
+++ b/src/DevHub/entity/community/NewAddon.jsx
@@ -0,0 +1,95 @@
+const { availableAddons, onSubmit } = props;
+
+const [selectedAddon, setSelectedAddon] = useState(null);
+
+return (
+ <>
+ {selectedAddon && (
+ setSelectedAddon(null),
+ }}
+ />
+ ),
+ forceEditActive: true,
+ hasConfigurePermissions: true,
+ Configurator: () => (
+
+ ),
+ }}
+ />
+ ),
+ }}
+ />
+ )}
+ {availableAddons && (
+
+
+
+ Add new addon
+
+
+ ({
+ label: it.title,
+ value: it.id,
+ })),
+ },
+ ],
+ rootProps: {
+ value: state.selectedAddon.id ?? null,
+ onValueChange: (value) =>
+ setSelectedAddon(
+ (availableAddons || []).find((it) => it.id === value)
+ ),
+ },
+ }}
+ />
+ >
+ ),
+ }}
+ />
+ )}
+ >
+);
diff --git a/src/DevHub/entity/community/Provider.jsx b/src/DevHub/entity/community/Provider.jsx
new file mode 100644
index 000000000..a6b48a67c
--- /dev/null
+++ b/src/DevHub/entity/community/Provider.jsx
@@ -0,0 +1,94 @@
+const { handle, Children } = props;
+
+const {
+ getAccountCommunityPermissions,
+ createCommunity,
+ updateCommunity,
+ deleteCommunity,
+ getCommunity,
+} = VM.require("${REPL_DEVHUB}/widget/DevHub.modules.contract-sdk");
+
+if (
+ !getCommunity ||
+ !getAccountCommunityPermissions ||
+ !createCommunity ||
+ !updateCommunity ||
+ !deleteCommunity
+) {
+ return Loading modules...
;
+}
+
+const CenteredMessage = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: ${(p) => p.height ?? "100%"};
+`;
+
+const [isLoading, setIsLoading] = useState(false);
+const [error, setError] = useState(null);
+// const [community, setCommunity] = useState(null);
+
+// TODO: This doesn't work as expected, it does not catch the error
+const community = Near.view("${REPL_DEVHUB_CONTRACT}", "get_community", {
+ handle,
+});
+
+const permissions = getAccountCommunityPermissions("${REPL_DEVHUB_CONTRACT}", {
+ account_id: context.accountId,
+ community_handle: handle,
+}) || {
+ can_configure: false,
+ can_delete: false,
+};
+
+if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+} else if (!community) {
+ return (
+
+ {`Community with handle "${handle}" not found.`}
+
+ );
+}
+
+function handleUpdateCommunity(v) {
+ updateCommunity(v);
+}
+
+community.addons = [
+ {
+ id: `${handle}-wiki-1`,
+ addon_id: "wiki",
+ display_name: "Screams and Whispers",
+ icon: "bi bi-book",
+ enabled: true,
+ },
+];
+
+community.configs = {
+ [`${handle}-wiki-1`]: {
+ parameters: JSON.stringify({
+ title: "Screams and Whispers",
+ description: "The Haunted Hack House Wiki",
+ content:
+ "π **Hack-o-ween is Upon Us!** π Prepare to be spooked and code in the dark shadows. As the moon rises on this haunted month, Boo's Horror House Wiki welcomes you to join us for the sinister celebration of Hacktoberfest and Hack-o-ween. π¦ **Hacktoberfest Tombstone** π¦ Don't be scared, contribute to our open-source projects during this eerie month. Dive into the world of code and contribute your terrifyingly awesome enhancements, bug fixes, and features. Each pull request brings you one step closer to the grand prize. Who knows what dark secrets and hidden gems you'll unearth in our haunted repositories? πΈοΈ **Hack-o-ween: Unmask the Coder Within** πΈοΈ Halloween is a time for costumes and masks, but this Hacktoberfest, it's all about unmasking the coder within you. Join our ghoulish coding challenges, hackathons, and themed coding nights. Share your code horrors and achievements in our spooky community. π **The Boo's Horror House Wiki** π *Screams and Whispers* beckon you to explore our wiki of the supernatural and macabre. Unlock the mysteries of coding in the dark and discover eerie coding tricks and tales from the crypt. Contribute to our spine-chilling articles and be a part of this horror-themed knowledge repository. It's a month filled with frights, delights, and code that bites! Join us for Hacktoberfest and Hack-o-ween, if you dare... π§ββοΈπ»π¦",
+ }),
+ },
+};
+
+return (
+
+);
diff --git a/src/DevHub/entity/community/Teams.jsx b/src/DevHub/entity/community/Teams.jsx
new file mode 100644
index 000000000..781032a13
--- /dev/null
+++ b/src/DevHub/entity/community/Teams.jsx
@@ -0,0 +1,61 @@
+const { handle } = props;
+
+const { getCommunity } = VM.require(
+ "${REPL_DEVHUB}/widget/DevHub.modules.contract-sdk"
+);
+
+const communityData = getCommunity({ handle });
+
+if (communityData === null) {
+ return Loading...
;
+}
+
+const UserList = ({ name, users }) => (
+
+ {(users ?? []).map((user, i) => (
+
+
+ {name + " #" + (i + 1)}
+
+
+
+
+
+
+
+
+ ))}
+
+);
+
+return (
+
+ ),
+ }}
+ />
+
+);
diff --git a/src/DevHub/entity/post/List.jsx b/src/DevHub/entity/post/List.jsx
new file mode 100644
index 000000000..570c11692
--- /dev/null
+++ b/src/DevHub/entity/post/List.jsx
@@ -0,0 +1,445 @@
+// This component implementation was forked from [IndexFeed], but it does not fully implement lazy loading.
+// While this component uses InfiniteScroll, it still loads the whole list of Post IDs in one view call.
+// The contract will need to be extended with pagination support, yet, even in the current state the page loads much faster.
+// [IndexFeed]: https://near.social/#/mob.near/widget/WidgetSource?src=mob.near/widget/IndexFeed
+
+function href(widgetName, linkProps) {
+ const linkPropsQuery = Object.entries(linkProps)
+ .filter(([_key, nullable]) => (nullable ?? null) !== null)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+
+ return `/#/${REPL_DEVHUB}/widget/gigs-board.pages.${widgetName}${
+ linkPropsQuery ? "?" : ""
+ }${linkPropsQuery}`;
+}
+/* INCLUDE: "core/lib/draftstate" */
+const DRAFT_STATE_STORAGE_KEY = "POST_DRAFT_STATE";
+let is_edit_or_add_post_transaction = false;
+let transaction_method_name;
+
+if (props.transactionHashes) {
+ const transaction = fetch("https://rpc.mainnet.near.org", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: "dontcare",
+ method: "tx",
+ params: [props.transactionHashes, context.accountId],
+ }),
+ });
+ transaction_method_name =
+ transaction?.body?.result?.transaction?.actions[0].FunctionCall.method_name;
+
+ is_edit_or_add_post_transaction =
+ transaction_method_name == "add_post" ||
+ transaction_method_name == "edit_post";
+
+ if (is_edit_or_add_post_transaction) {
+ Storage.privateSet(DRAFT_STATE_STORAGE_KEY, undefined);
+ }
+}
+
+const onDraftStateChange = (draftState) =>
+ Storage.privateSet(DRAFT_STATE_STORAGE_KEY, JSON.stringify(draftState));
+let draftState;
+try {
+ draftState = JSON.parse(Storage.privateGet(DRAFT_STATE_STORAGE_KEY));
+} catch (e) {}
+/* END_INCLUDE: "core/lib/draftstate" */
+
+initState({
+ period: "week",
+});
+
+function defaultRenderItem(postId, additionalProps) {
+ if (!additionalProps) {
+ additionalProps = {};
+ }
+ // It is important to have a non-zero-height element as otherwise InfiniteScroll loads too many items on initial load
+ return (
+
+
+
+ );
+}
+
+const renderItem = props.renderItem ?? defaultRenderItem;
+
+const cachedRenderItem = (item, i) => {
+ if (props.searchResult && props.searchResult.keywords[item]) {
+ return renderItem(item, {
+ searchKeywords: props.searchResult.keywords[item],
+ });
+ }
+
+ const key = JSON.stringify(item);
+
+ if (!(key in state.cachedItems)) {
+ state.cachedItems[key] = renderItem(item);
+ State.update();
+ }
+ return state.cachedItems[key];
+};
+
+const initialRenderLimit = props.initialRenderLimit ?? 3;
+const addDisplayCount = props.nextLimit ?? initialRenderLimit;
+
+function getPostsByLabel() {
+ let postIds = Near.view("${REPL_DEVHUB_CONTRACT}", "get_posts_by_label", {
+ label: props.tag,
+ });
+ if (postIds) {
+ postIds.reverse();
+ }
+ return postIds;
+}
+
+function getPostsByAuthor() {
+ let postIds = Near.view("${REPL_DEVHUB_CONTRACT}", "get_posts_by_author", {
+ author: props.author,
+ });
+ if (postIds) {
+ postIds.reverse();
+ }
+ return postIds;
+}
+
+function intersectPostsWithLabel(postIds) {
+ if (props.tag) {
+ let postIdLabels = getPostsByLabel();
+ if (postIdLabels === null) {
+ // wait until postIdLabels are loaded
+ return null;
+ }
+ postIdLabels = new Set(postIdLabels);
+ return postIds.filter((id) => postIdLabels.has(id));
+ }
+ return postIds;
+}
+
+function intersectPostsWithAuthor(postIds) {
+ if (props.author) {
+ let postIdsByAuthor = getPostsByAuthor();
+ if (postIdsByAuthor == null) {
+ // wait until postIdsByAuthor are loaded
+ return null;
+ } else {
+ postIdsByAuthor = new Set(postIdsByAuthor);
+ return postIds.filter((id) => postIdsByAuthor.has(id));
+ }
+ }
+ return postIds;
+}
+
+const ONE_DAY = 60 * 60 * 24 * 1000;
+const ONE_WEEK = 60 * 60 * 24 * 1000 * 7;
+const ONE_MONTH = 60 * 60 * 24 * 1000 * 30;
+
+function getHotnessScore(post) {
+ //post.id - shows the age of the post, should grow exponentially, since newer posts are more important
+ //post.likes.length - linear value
+ const age = Math.pow(post.id, 5);
+ const comments = post.comments;
+ const commentAge = comments.reduce((sum, age) => sum + Math.pow(age, 5), 0);
+ const totalAge = age + commentAge;
+ //use log functions to make likes score and exponentially big age score close to each other
+ return Math.log10(post.likes.length) + Math.log(Math.log10(totalAge));
+}
+
+const getPeriodText = (period) => {
+ let text = "Last 24 hours";
+ if (period === "week") {
+ text = "Last week";
+ }
+ if (period === "month") {
+ text = "Last month";
+ }
+ return text;
+};
+
+const findHottestsPosts = (postIds, period) => {
+ let allPosts;
+ if (!state.allPosts) {
+ allPosts = Near.view("${REPL_DEVHUB_CONTRACT}", "get_posts");
+ if (!allPosts) {
+ return [];
+ }
+ State.update({ allPosts });
+ } else {
+ allPosts = state.allPosts;
+ }
+ let postIdsSet = new Set(postIds);
+ let posts = allPosts.filter((post) => postIdsSet.has(post.id));
+
+ let periodTime = ONE_DAY;
+ if (period === "week") {
+ periodTime = ONE_WEEK;
+ }
+ if (period === "month") {
+ periodTime = ONE_MONTH;
+ }
+ const periodLimitedPosts = posts.filter((post) => {
+ const timestamp = post.snapshot.timestamp / 1000000;
+ return Date.now() - timestamp < periodTime;
+ });
+ const modifiedPosts = periodLimitedPosts.map((post) => {
+ const comments =
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_children_ids", {
+ post_id: post.id,
+ }) || [];
+ post = { ...post, comments };
+ return {
+ ...post,
+ postScore: getHotnessScore(post),
+ };
+ });
+ modifiedPosts.sort((a, b) => b.postScore - a.postScore);
+ return modifiedPosts.map((post) => post.id);
+};
+
+let postIds;
+if (props.searchResult) {
+ postIds = props.searchResult.postIds;
+ postIds = intersectPostsWithLabel(postIds);
+ postIds = intersectPostsWithAuthor(postIds);
+} else if (props.tag) {
+ postIds = getPostsByLabel();
+ postIds = intersectPostsWithAuthor(postIds);
+} else if (props.author) {
+ postIds = getPostsByAuthor();
+} else if (props.recency == "all") {
+ postIds = Near.view("${REPL_DEVHUB_CONTRACT}", "get_all_post_ids");
+ if (postIds) {
+ postIds.reverse();
+ }
+} else {
+ postIds = Near.view("${REPL_DEVHUB_CONTRACT}", "get_children_ids");
+ if (postIds) {
+ postIds.reverse();
+ }
+}
+
+if (props.recency == "hot") {
+ postIds = findHottestsPosts(postIds, state.period);
+}
+
+const loader = (
+
+
+ Loading ...
+
+);
+
+if (postIds === null) {
+ return loader;
+}
+const initialItems = postIds;
+//const initialItems = postIds.map(postId => ({ id: postId, ...Near.view(nearDevGovGigsContractAccountId, "get_post", { post_id: postId }) }));
+
+// const computeFetchFrom = (items, limit) => {
+// if (!items || items.length < limit) {
+// return false;
+// }
+// const blockHeight = items[items.length - 1].blockHeight;
+// return index.options.order === "desc" ? blockHeight - 1 : blockHeight + 1;
+// };
+
+// const mergeItems = (newItems) => {
+// const items = [
+// ...new Set([...newItems, ...state.items].map((i) => JSON.stringify(i))),
+// ].map((i) => JSON.parse(i));
+// items.sort((a, b) => a.blockHeight - b.blockHeight);
+// if (index.options.order === "desc") {
+// items.reverse();
+// }
+// return items;
+// };
+
+const jInitialItems = JSON.stringify(initialItems);
+if (state.jInitialItems !== jInitialItems) {
+ // const jIndex = JSON.stringify(index);
+ // if (jIndex !== state.jIndex) {
+ State.update({
+ jIndex,
+ jInitialItems,
+ items: initialItems,
+ fetchFrom: false,
+ //nextFetchFrom: computeFetchFrom(initialItems, index.options.limit),
+ nextFetchFrom: false,
+ displayCount: initialRenderLimit,
+ cachedItems: {},
+ });
+ // } else {
+ // State.update({
+ // jInitialItems,
+ // items: mergeItems(initialItems),
+ // });
+ // }
+}
+
+if (state.fetchFrom) {
+ // TODO: fetchFrom
+ // const limit = addDisplayCount;
+ // const newItems = Social.index(
+ // index.action,
+ // index.key,
+ // Object.assign({}, index.options, {
+ // from: state.fetchFrom,
+ // subscribe: undefined,
+ // limit,
+ // })
+ // );
+ // if (newItems !== null) {
+ // State.update({
+ // items: mergeItems(newItems),
+ // fetchFrom: false,
+ // nextFetchFrom: computeFetchFrom(newItems, limit),
+ // });
+ // }
+}
+
+const makeMoreItems = () => {
+ State.update({
+ displayCount: state.displayCount + addDisplayCount,
+ });
+ if (
+ state.items.length - state.displayCount < addDisplayCount * 2 &&
+ !state.fetchFrom &&
+ state.nextFetchFrom &&
+ state.nextFetchFrom !== state.fetchFrom
+ ) {
+ State.update({
+ fetchFrom: state.nextFetchFrom,
+ });
+ }
+};
+
+const fetchMore =
+ props.manual &&
+ (state.fetchFrom && state.items.length < state.displayCount
+ ? loader
+ : state.displayCount < state.items.length && (
+
+ ));
+
+const items = state.items ? state.items.slice(0, state.displayCount) : [];
+
+const renderedItems = items.map(cachedRenderItem);
+
+const Head =
+ props.recency == "hot" ? (
+
+
+
+ Hottest Posts
+
+
+
+
+
+
+
+ ) : (
+ <>>
+ );
+
+return (
+ <>
+ {Head}
+ {is_edit_or_add_post_transaction ? (
+
+ Post {transaction_method_name == "edit_post" ? "edited" : "added"}{" "}
+ successfully. Back to{" "}
+
+ feed
+
+
+ ) : state.items.length > 0 ? (
+
+ {renderedItems}
+
+ ) : (
+
+ No posts {props.searchResult ? "matches search" : ""}
+ {props.recency === "hot"
+ ? " in " + getPeriodText(state.period).toLowerCase()
+ : ""}
+
+ )}
+ >
+);
diff --git a/src/DevHub/entity/post/Panel.jsx b/src/DevHub/entity/post/Panel.jsx
new file mode 100644
index 000000000..81faf2c17
--- /dev/null
+++ b/src/DevHub/entity/post/Panel.jsx
@@ -0,0 +1,755 @@
+function href(widgetName, linkProps) {
+ const linkPropsQuery = Object.entries(linkProps)
+ .filter(([_key, nullable]) => (nullable ?? null) !== null)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+
+ return `/#/${REPL_DEVHUB}/widget/gigs-board.pages.${widgetName}${
+ linkPropsQuery ? "?" : ""
+ }${linkPropsQuery}`;
+}
+//////////////////////////////////////////////////////////////////////
+///STOPWORDS//////////////////////////////////////////////////////////
+const stopWords = [
+ "about",
+ "above",
+ "after",
+ "again",
+ "against",
+ "all",
+ "and",
+ "any",
+ "are",
+ "because",
+ "been",
+ "before",
+ "being",
+ "below",
+ "between",
+ "both",
+ "but",
+ "can",
+ "cannot",
+ "could",
+ "did",
+ "does",
+ "doing",
+ "down",
+ "during",
+ "each",
+ "etc",
+ "few",
+ "for",
+ "from",
+ "further",
+ "had",
+ "has",
+ "have",
+ "having",
+ "her",
+ "hers",
+ "herself",
+ "him",
+ "himself",
+ "his",
+ "how",
+ "into",
+ "its",
+ "itself",
+ "just",
+ "more",
+ "most",
+ "myself",
+ "nor",
+ "not",
+ "now",
+ "off",
+ "once",
+ "only",
+ "other",
+ "our",
+ "ours",
+ "ourselves",
+ "out",
+ "over",
+ "own",
+ "same",
+ "she",
+ "should",
+ "some",
+ "still",
+ "such",
+ "than",
+ "that",
+ "the",
+ "their",
+ "theirs",
+ "them",
+ "themselves",
+ "then",
+ "there",
+ "these",
+ "they",
+ "this",
+ "those",
+ "through",
+ "too",
+ "under",
+ "until",
+ "very",
+ "was",
+ "were",
+ "what",
+ "when",
+ "where",
+ "which",
+ "while",
+ "who",
+ "whom",
+ "why",
+ "will",
+ "with",
+ "you",
+ "your",
+ "yours",
+ "yourself",
+ "yourselves",
+ "www",
+ "http",
+ "com",
+];
+
+const stopWordsDictionary = {};
+for (let i = 0; i < stopWords.length; i++) {
+ stopWordsDictionary[stopWords[i]] = true;
+}
+
+function isStopWord(word) {
+ return stopWordsDictionary.hasOwnProperty(word.toLowerCase());
+}
+//////////////////////////////////////////////////////////////////////
+///SYNONYMS///////////////////////////////////////////////////////////
+const synonyms = {
+ ether: "ethereum",
+ eth: "ethereum",
+ either: "ethereum",
+ app: "application",
+ cryptocyrrency: "crypto",
+ developerdao: "devdao",
+ dev: "develop",
+ doc: "document",
+ lib: "librari",
+ saw: "see",
+ seen: "see",
+ tweet: "twitter",
+ paid: "pai",
+ src: "sourc",
+};
+
+const applySynonym = (word) => {
+ if (synonyms.hasOwnProperty(word.toLowerCase())) {
+ return synonyms[word];
+ }
+ return word;
+};
+//////////////////////////////////////////////////////////////////////
+///STEMMING///////////////////////////////////////////////////////////
+const step2list = {
+ ational: "ate",
+ tional: "tion",
+ enci: "ence",
+ anci: "ance",
+ izer: "ize",
+ bli: "ble",
+ alli: "al",
+ entli: "ent",
+ eli: "e",
+ ousli: "ous",
+ ization: "ize",
+ ation: "ate",
+ ator: "ate",
+ alism: "al",
+ iveness: "ive",
+ fulness: "ful",
+ ousness: "ous",
+ aliti: "al",
+ iviti: "ive",
+ biliti: "ble",
+ logi: "log",
+};
+
+/** @type {Record} */
+const step3list = {
+ icate: "ic",
+ ative: "",
+ alize: "al",
+ iciti: "ic",
+ ical: "ic",
+ ful: "",
+ ness: "",
+};
+
+const gt0 = /^([^aeiou][^aeiouy]*)?([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)/;
+const eq1 =
+ /^([^aeiou][^aeiouy]*)?([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)([aeiouy][aeiou]*)?$/;
+const gt1 =
+ /^([^aeiou][^aeiouy]*)?(([aeiouy][aeiou]*)([^aeiou][^aeiouy]*)){2,}/;
+const vowelInStem = /^([^aeiou][^aeiouy]*)?[aeiouy]/;
+const consonantLike = /^([^aeiou][^aeiouy]*)[aeiouy][^aeiouwxy]$/;
+
+// Exception expressions.
+const sfxLl = /ll$/;
+const sfxE = /^(.+?)e$/;
+const sfxY = /^(.+?)y$/;
+const sfxIon = /^(.+?(s|t))(ion)$/;
+const sfxEdOrIng = /^(.+?)(ed|ing)$/;
+const sfxAtOrBlOrIz = /(at|bl|iz)$/;
+const sfxEED = /^(.+?)eed$/;
+const sfxS = /^.+?[^s]s$/;
+const sfxSsesOrIes = /^.+?(ss|i)es$/;
+const sfxMultiConsonantLike = /([^aeiouylsz])\1$/;
+const step2 =
+ /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
+const step3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
+const step4 =
+ /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
+
+/**
+ * Get the stem from a given value.
+ *
+ * @param {string} value
+ * Value to stem.
+ * @returns {string}
+ * Stem for `value`
+ */
+// eslint-disable-next-line complexity
+function stemmer(value) {
+ let result = value.toLowerCase();
+
+ // Exit early.
+ if (result.length < 3) {
+ return result;
+ }
+
+ /** @type {boolean} */
+ let firstCharacterWasLowerCaseY = false;
+
+ // Detect initial `y`, make sure it never matches.
+ if (
+ result.codePointAt(0) === 121 // Lowercase Y
+ ) {
+ firstCharacterWasLowerCaseY = true;
+ result = "Y" + result.slice(1);
+ }
+
+ // Step 1a.
+ if (sfxSsesOrIes.test(result)) {
+ // Remove last two characters.
+ result = result.slice(0, -2);
+ } else if (sfxS.test(result)) {
+ // Remove last character.
+ result = result.slice(0, -1);
+ }
+
+ /** @type {RegExpMatchArray|null} */
+ let match;
+
+ // Step 1b.
+ if ((match = sfxEED.exec(result))) {
+ if (gt0.test(match[1])) {
+ // Remove last character.
+ result = result.slice(0, -1);
+ }
+ } else if ((match = sfxEdOrIng.exec(result)) && vowelInStem.test(match[1])) {
+ result = match[1];
+
+ if (sfxAtOrBlOrIz.test(result)) {
+ // Append `e`.
+ result += "e";
+ } else if (sfxMultiConsonantLike.test(result)) {
+ // Remove last character.
+ result = result.slice(0, -1);
+ } else if (consonantLike.test(result)) {
+ // Append `e`.
+ result += "e";
+ }
+ }
+
+ // Step 1c.
+ if ((match = sfxY.exec(result)) && vowelInStem.test(match[1])) {
+ // Remove suffixing `y` and append `i`.
+ result = match[1] + "i";
+ }
+
+ // Step 2.
+ if ((match = step2.exec(result)) && gt0.test(match[1])) {
+ result = match[1] + step2list[match[2]];
+ }
+
+ // Step 3.
+ if ((match = step3.exec(result)) && gt0.test(match[1])) {
+ result = match[1] + step3list[match[2]];
+ }
+
+ // Step 4.
+ if ((match = step4.exec(result))) {
+ if (gt1.test(match[1])) {
+ result = match[1];
+ }
+ } else if ((match = sfxIon.exec(result)) && gt1.test(match[1])) {
+ result = match[1];
+ }
+
+ // Step 5.
+ if (
+ (match = sfxE.exec(result)) &&
+ (gt1.test(match[1]) ||
+ (eq1.test(match[1]) && !consonantLike.test(match[1])))
+ ) {
+ result = match[1];
+ }
+
+ if (sfxLl.test(result) && gt1.test(result)) {
+ result = result.slice(0, -1);
+ }
+
+ // Turn initial `Y` back to `y`.
+ if (firstCharacterWasLowerCaseY) {
+ result = "y" + result.slice(1);
+ }
+
+ return result;
+}
+
+//////////////////////////////////////////////////////////////////////
+///SPELLCHECK/////////////////////////////////////////////////////////
+function levenshteinDistance(s, t, threshold) {
+ const BIG_NUMBER = 10000;
+ if (s == null || t == null) {
+ return BIG_NUMBER;
+ }
+ if (threshold < 0) {
+ return BIG_NUMBER;
+ }
+ let n = s.length;
+ let m = t.length;
+ if (Math.abs(n - m) >= threshold) {
+ return BIG_NUMBER;
+ }
+
+ // if one string is empty, the edit distance is necessarily the length of the other
+ if (n == 0) {
+ return m <= threshold ? m : BIG_NUMBER;
+ } else if (m == 0) {
+ return n <= threshold ? n : BIG_NUMBER;
+ }
+
+ if (n > m) {
+ // swap the two strings to consume less memory
+ let temp = s;
+ s = t;
+ t = temp;
+ let tempSize = n;
+ n = m;
+ m = tempSize;
+ }
+
+ let p = Array.from({ length: n + 1 }, () => 0); // 'previous' cost array, horizontally
+ let d = Array.from({ length: n + 1 }, () => 0); // cost array, horizontally
+ let _d; // placeholder to assist in swapping p and d
+
+ // fill in starting table values
+ const boundary = Math.min(n, threshold) + 1;
+ for (let i = 0; i < boundary; i++) {
+ p[i] = i;
+ }
+ // these fills ensure that the value above the rightmost entry of our
+ // stripe will be ignored in following loop iterations
+ for (let i = boundary; i < p.length; i++) {
+ p[i] = BIG_NUMBER;
+ }
+ for (let i = 0; i < d.length; i++) {
+ d[i] = BIG_NUMBER;
+ }
+
+ // iterates through t
+ for (let j = 1; j <= m; j++) {
+ const t_j = t.charAt(j - 1); // jth character of t
+ d[0] = j;
+
+ // compute stripe indices, constrain to array size
+ const min = Math.max(1, j - threshold);
+ const max = j > BIG_NUMBER - threshold ? n : Math.min(n, j + threshold);
+
+ // the stripe may lead off of the table if s and t are of different sizes
+ if (min > max) {
+ return BIG_NUMBER;
+ }
+
+ // ignore entry left of leftmost
+ if (min > 1) {
+ d[min - 1] = BIG_NUMBER;
+ }
+
+ // iterates through [min, max] in s
+ for (let i = min; i <= max; i++) {
+ if (s.charAt(i - 1) == t_j) {
+ // diagonally left and up
+ d[i] = p[i - 1];
+ } else {
+ // 1 + minimum of cell to the left, to the top, diagonally left and up
+ d[i] = 1 + Math.min(Math.min(d[i - 1], p[i]), p[i - 1]);
+ }
+ }
+
+ // copy current distance counts to 'previous row' distance counts
+ _d = p;
+ p = d;
+ d = _d;
+ }
+ // we don't need to check for threshold here because we did it inside the loop
+ return p[n] <= threshold ? p[n] : BIG_NUMBER;
+}
+
+const spellcheckQueryProcessing = (query, dictionary) => {
+ // Split text document into words
+ const words = stemAndFilterQuery(query);
+ const dictionaryArray = Object.keys(dictionary);
+ // Iterate over each word in the text
+ for (let i = 0; i < words.length; i++) {
+ let word = words[i].toLowerCase().replace(/[^a-z0-9]/g, "");
+
+ // If the word is not in the dictionary, find the closest match
+ if (!dictionary.hasOwnProperty(word)) {
+ let closestMatch = undefined;
+ let closestDistance = word.length;
+ let allowedDistance = Math.min(word.length - 1, 3);
+ // Iterate over each word in the dictionary
+ if (word.length > 1) {
+ for (let j = 0; j < dictionaryArray.length; j++) {
+ let dictWord = dictionaryArray[j];
+ let distance = levenshteinDistance(word, dictWord, allowedDistance);
+
+ // If the distance is less than the closest distance, update the closest match
+ if (distance <= allowedDistance && distance < closestDistance) {
+ closestMatch = dictWord;
+ closestDistance = distance;
+ }
+ }
+ }
+ // Replace the misspelled word with the closest match
+ words[i] = closestMatch;
+ }
+ }
+ return words.filter((word) => !!word);
+};
+
+//////////////////////////////////////////////////////////////////////
+///INDEXER&SEARCH/////////////////////////////////////////////////////
+const fillDictionaryWith = (dict, text, id) => {
+ let word = "";
+ for (let i = 0; i < text.length; i++) {
+ const char = text.charAt(i);
+ const nextChar = text.charAt(i + 1);
+ if (/\w/.test(char) || (char === "." && /\w/.test(nextChar))) {
+ word += char.toLowerCase();
+ } else if (word.length > 0) {
+ const processedWord = applySynonym(stemmer(word));
+ if (processedWord.length > 1 && !isStopWord(processedWord)) {
+ const oldValue = dict[processedWord] || [];
+ dict[processedWord] = [...oldValue, id];
+ }
+ word = "";
+ }
+ }
+ const processedWord = applySynonym(stemmer(word));
+ if (processedWord.length > 1 && !isStopWord(processedWord)) {
+ const oldValue = dict[stemmer(processedWord)] || [];
+ dict[stemmer(processedWord)] = [...oldValue, id];
+ }
+ return dict;
+};
+
+const buildIndex = (posts) => {
+ let index = {};
+
+ posts.forEach((post) => {
+ const title = post.snapshot.name;
+ const labels = post.snapshot.labels.join(" ");
+ const text = post.snapshot.description;
+ const postType = post.snapshot.post_type;
+ const authorId = post.author_id;
+ const postText = `${authorId} ${postType} ${title} ${labels} ${text}`;
+ index = fillDictionaryWith(index, postText, post.id);
+ });
+ return index;
+};
+
+const stemAndFilterQuery = (query) => {
+ return Object.keys(fillDictionaryWith({}, query));
+};
+
+const sortSearchResult = (searchResult) => {
+ // create a map to count the frequency of each element
+ const freq = new Map();
+ for (const num of searchResult) {
+ freq.set(num, (freq.get(num) || 0) + 1);
+ }
+
+ // define a custom comparison function to sort the array
+ function compare(a, b) {
+ // compare the frequency of the two elements
+ const freqDiff = freq.get(b) - freq.get(a);
+ if (freqDiff !== 0) {
+ return freqDiff; // if they have different frequency, sort by frequency
+ } else {
+ return 0; // if they have the same frequency, leave as it is. Will be sorted by search term, by date
+ }
+ }
+
+ // sort the array using the custom comparison function
+ searchResult.sort(compare);
+ return searchResult.filter(
+ (elem, index) => searchResult.indexOf(elem) === index
+ );
+};
+
+const search = (processedQueryArray, index) => {
+ return sortSearchResult(
+ processedQueryArray.flatMap((queryWord) => {
+ const termSearchRes = index[queryWord].reverse();
+ const termSortedSearchRes = sortSearchResult(termSearchRes);
+ return termSortedSearchRes;
+ })
+ );
+};
+
+//////////////////////////////////////////////////////////////////////
+///UI&UX//////////////////////////////////////////////////////////////
+//Run search and spelling computation every time the search bar modified
+//but no more frequent than 1 time per 1.5 seconds
+const amountOfResultsToShowFirst = 5;
+
+const buildPostsIndex = () => {
+ return Near.asyncView("devgovgigs.near", "get_posts").then((posts) => {
+ const index = buildIndex(posts);
+ const data = posts.reduce((acc, post) => {
+ acc[post.id] = post;
+ return acc;
+ }, {});
+ return { index, data };
+ });
+};
+
+const getProcessedPostsCached = () => {
+ return useCache(() => buildPostsIndex(), "processedPostsCached");
+};
+
+if (!state.interval) {
+ let termStorage = "";
+ Storage.privateSet("term", "");
+ setInterval(() => {
+ const currentInput = Storage.privateGet("term");
+ if (currentInput !== termStorage) {
+ termStorage = currentInput;
+ computeResults(termStorage);
+ }
+ }, 1500);
+ State.update({
+ interval: true,
+ });
+}
+
+const computeResults = (term) => {
+ const start = new Date().getTime();
+ const processedPostsCached = useCache(
+ () =>
+ buildPostsIndex().then((processedPosts) => {
+ // Run query first time posts retrieved
+ const query = term;
+ const processedQuery = spellcheckQueryProcessing(
+ query,
+ processedPosts.index
+ );
+ const searchResult = search(processedQuery, processedPosts.index);
+ console.log(processedQuery);
+ console.log(searchResult);
+ State.update({
+ searchResult,
+ shownSearchResults: searchResult.slice(0, amountOfResultsToShowFirst),
+ processedQuery,
+ loading: false,
+ });
+ return processedPosts;
+ }),
+ "processedPostsCached"
+ );
+ if (processedPostsCached) {
+ // Run query every other time after data retrieved and cached
+ const query = term;
+ const processedQuery = spellcheckQueryProcessing(
+ query,
+ processedPostsCached.index
+ );
+ const searchResult = search(processedQuery, processedPostsCached.index);
+ console.log(processedQuery);
+ console.log(searchResult);
+ State.update({
+ searchResult,
+ shownSearchResults: searchResult.slice(0, 10),
+ processedQuery,
+ loading: false,
+ });
+ }
+ const end = new Date().getTime();
+ console.log("search time: ", end - start);
+};
+
+const updateInput = (term) => {
+ Storage.privateSet("term", term);
+ State.update({
+ term,
+ loading: true,
+ });
+};
+
+const getSearchResultsKeywordsFor = (postId) => {
+ const index = getProcessedPostsCached().index;
+ return state.processedQuery.filter((queryWord) => {
+ return index[queryWord].includes(postId);
+ });
+};
+
+const showMoreSearchResults = () => {
+ const shownSearchResults = state.shownSearchResults || [];
+ const newShownSearchResults = state.searchResult.slice(
+ 0,
+ shownSearchResults.length + amountOfResultsToShowFirst
+ );
+ State.update({ shownSearchResults: newShownSearchResults });
+};
+
+return (
+ <>
+
+
+
+ {state.loading ? (
+
+ ) : (
+
+ )}
+
+
updateInput(e.target.value)}
+ placeholder={props.placeholder ?? `Search Posts`}
+ />
+
+
+
+
+
+
+
+
+
+ {props.children}
+
+
+ {state.processedQuery &&
+ state.processedQuery.length > 0 &&
+ state.term.toLowerCase().trim() !== state.processedQuery.join(" ") && (
+
+ Looking for
+ {state.processedQuery.join(" ")} :
+
+ )}
+ {state.term && state.term.length > 1 && state.searchResult ? (
+ {
+ return [postId, getSearchResultsKeywordsFor(postId)];
+ })
+ ),
+ },
+ recency: props.recency,
+ tag: props.tag,
+ author: props.author,
+ }}
+ key={key}
+ />
+ ) : (
+
+ )}
+ >
+);
diff --git a/src/DevHub/modules/contract-sdk.jsx b/src/DevHub/modules/contract-sdk.jsx
new file mode 100644
index 000000000..7f68499f4
--- /dev/null
+++ b/src/DevHub/modules/contract-sdk.jsx
@@ -0,0 +1,218 @@
+function getRootMembers() {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_root_members") ?? null;
+}
+
+function hasModerator({ account_id }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "has_moderator", { account_id }) ??
+ null
+ );
+}
+
+function createCommunity({ inputs }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "create_community", { inputs });
+}
+
+function getCommunity({ handle }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_community", { handle }) ?? null
+ );
+}
+
+function getFeaturedCommunities() {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_featured_communities") ?? null
+ );
+}
+
+function getAccountCommunityPermissions({ account_id, community_handle }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_account_community_permissions", {
+ account_id,
+ community_handle,
+ }) ?? null
+ );
+}
+
+function updateCommunity({ handle, community }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "update_community", {
+ handle,
+ community,
+ });
+}
+
+function deleteCommunity({ handle }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "delete_community", { handle });
+}
+
+function updateCommunityBoard({ handle, board }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "update_community_board", {
+ handle,
+ board,
+ });
+}
+
+function updateCommunityGithub({ handle, github }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "update_community_github", {
+ handle,
+ github,
+ });
+}
+
+function addCommunityAddon({ handle, config }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "add_community_addon", {
+ community_handle: handle,
+ addon_config: config,
+ });
+}
+
+function updateCommunityAddon({ handle, config }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "update_community_addon", {
+ community_handle: handle,
+ addon_config: config,
+ });
+}
+
+function removeCommunityAddon({ handle, config_id }) {
+ return Near.call("${REPL_DEVHUB_CONTRACT}", "remove_community_addon", {
+ community_handle: handle,
+ config_id,
+ });
+}
+
+function getAccessControlInfo() {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_access_control_info") ?? null
+ );
+}
+
+function getAllAuthors() {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_all_authors") ?? null;
+}
+
+function getAllCommunitiesMetadata() {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_all_communities_metadata") ?? null
+ );
+}
+
+function getAvailableAddons() {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_available_addons") ?? null;
+}
+
+function getCommunityAddons({ handle }) {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_community_addons", {
+ handle,
+ });
+}
+
+function getCommunityAddonConfigs({ handle }) {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_community_addon_configs", {
+ handle,
+ });
+}
+
+function getAllLabels() {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_all_labels") ?? null;
+}
+
+function getPost({ post_id }) {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_post", { post_id }) ?? null;
+}
+
+function getPostsByAuthor({ author }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_posts_by_author", { author }) ??
+ null
+ );
+}
+
+function getPostsByLabel({ label }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_posts_by_label", {
+ label,
+ }) ?? null
+ );
+}
+
+function getAddons({ handle }) {
+ return Near.view("${REPL_DEVHUB_CONTRACT}", "get_addons", { handle }) ?? null;
+}
+
+function setAddons({ handle, addons }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_addons", { handle, addons }) ??
+ null
+ );
+}
+
+function getConfig({ config_id }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "get_config", { config_id }) ?? null
+ );
+}
+
+function setConfig({ handle, config_id, config }) {
+ return (
+ Near.view("${REPL_DEVHUB_CONTRACT}", "set_config", {
+ handle,
+ config_id,
+ config,
+ }) ?? null
+ );
+}
+
+function useQuery(name, params) {
+ const initialState = { data: null, error: null, isLoading: true };
+
+ const cacheState = useCache(
+ () =>
+ Near.asyncView(
+ "${REPL_DEVHUB_CONTRACT}",
+ ["get", name].join("_"),
+ params ?? {}
+ )
+ .then((response) => ({
+ ...initialState,
+ data: response ?? null,
+ isLoading: false,
+ }))
+ .catch((error) => ({
+ ...initialState,
+ error: props?.error ?? error,
+ isLoading: false,
+ })),
+
+ JSON.stringify({ name, params }),
+ { subscribe: false } // NOTE: I'm turning off subscribe to stop the constant re-rendering
+ );
+
+ return cacheState === null ? initialState : cacheState;
+}
+
+return {
+ getRootMembers,
+ hasModerator,
+ createCommunity,
+ getCommunity,
+ getFeaturedCommunities,
+ getAccountCommunityPermissions,
+ updateCommunity,
+ deleteCommunity,
+ updateCommunityBoard,
+ updateCommunityGithub,
+ addCommunityAddon,
+ updateCommunityAddon,
+ removeCommunityAddon,
+ getAccessControlInfo,
+ getAllAuthors,
+ getAllCommunitiesMetadata,
+ getAvailableAddons,
+ getCommunityAddons,
+ getCommunityAddonConfigs,
+ getAllLabels,
+ getPost,
+ getPostsByAuthor,
+ getPostsByLabel,
+ useQuery,
+};
diff --git a/src/DevHub/modules/utils.jsx b/src/DevHub/modules/utils.jsx
new file mode 100644
index 000000000..9a8b94870
--- /dev/null
+++ b/src/DevHub/modules/utils.jsx
@@ -0,0 +1,64 @@
+const Struct = {
+ deepFieldUpdate: (
+ node,
+ { input, params, path: [nextNodeKey, ...remainingPath], via: toFieldValue }
+ ) => ({
+ ...node,
+
+ [nextNodeKey]:
+ remainingPath.length > 0
+ ? Struct.deepFieldUpdate(
+ Struct.typeMatch(node[nextNodeKey]) ||
+ Array.isArray(node[nextNodeKey])
+ ? node[nextNodeKey]
+ : {
+ ...((node[nextNodeKey] ?? null) !== null
+ ? { __archivedLeaf__: node[nextNodeKey] }
+ : {}),
+ },
+
+ { input, path: remainingPath, via: toFieldValue }
+ )
+ : toFieldValue({
+ input,
+ lastKnownValue: node[nextNodeKey],
+ params,
+ }),
+ }),
+
+ isEqual: (input1, input2) =>
+ Struct.typeMatch(input1) && Struct.typeMatch(input2)
+ ? JSON.stringify(Struct.toOrdered(input1)) ===
+ JSON.stringify(Struct.toOrdered(input2))
+ : false,
+
+ toOrdered: (input) =>
+ Object.keys(input)
+ .sort()
+ .reduce((output, key) => ({ ...output, [key]: input[key] }), {}),
+
+ pick: (object, subsetKeys) =>
+ Object.fromEntries(
+ Object.entries(object ?? {}).filter(([key, _]) =>
+ subsetKeys.includes(key)
+ )
+ ),
+
+ typeMatch: (input) =>
+ input !== null && typeof input === "object" && !Array.isArray(input),
+};
+
+function href({ gateway, widgetSrc, params }) {
+ if (params) {
+ params = (Object.entries(params) || [])
+ .filter(([_key, nullable]) => (nullable ?? null) !== null)
+ .map(([key, value]) => `${key}=${value}`)
+ .join("&");
+ }
+
+ return `${gateway ? `https://${gateway}` : ""}/${widgetSrc}/${
+ params ? `?${params}` : ""
+ }`;
+}
+
+return { href, Struct };
diff --git a/src/DevHub/pages/addon/index.jsx b/src/DevHub/pages/addon/index.jsx
new file mode 100644
index 000000000..8f425fc6e
--- /dev/null
+++ b/src/DevHub/pages/addon/index.jsx
@@ -0,0 +1,78 @@
+const { addon_id, config } = props;
+
+const addon = {
+ id: "wiki", // this could be determined by the Type
+ title: "Wiki",
+ description: "Add a wiki to your community.",
+ icon: "bi bi-book",
+ widgets: {
+ viewer: "${REPL_DEVHUB}/widget/DevHub.entity.addon.wiki.Viewer",
+ configurator: "${REPL_DEVHUB}/widget/DevHub.entity.addon.wiki.Configurator",
+ },
+};
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+`;
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+`;
+
+const Content = styled.div`
+ flex: 1;
+ background-color: #f0f0f0;
+ padding: 20px;
+ overflow: auto;
+`;
+
+const SettingsIcon = styled.div`
+ cursor: pointer;
+`;
+
+const [showConfigure, setShowConfigure] = useState(false);
+
+const parameters = (config.parameters && JSON.parse(config.parameters)) || {};
+
+const [tempParameters, setTempParameters] = useState(parameters); // this is just for demonstrative purposes
+
+return (
+
+
+ Addon: {addon.title}
+ setShowConfigure(!showConfigure)}>
+
+
+
+
+ {showConfigure ? (
+
+
Settings Configuration
+ {/* This may want to point to a new configuration "page", this can have it's own provider */}
+ {
+ console.log("data", data);
+ setTempParameters(data);
+ },
+ }}
+ />
+
+ ) : (
+
+
View Content
+
+
+ )}
+
+
+);
diff --git a/src/DevHub/pages/communities.jsx b/src/DevHub/pages/communities.jsx
new file mode 100644
index 000000000..b6dbc77e4
--- /dev/null
+++ b/src/DevHub/pages/communities.jsx
@@ -0,0 +1,273 @@
+const { getAllCommunitiesMetadata, createCommunity } = VM.require(
+ "${REPL_DEVHUB}/widget/DevHub.modules.contract-sdk"
+);
+
+if (!getAllCommunitiesMetadata || !createCommunity) {
+ return Loading modules...
;
+}
+
+const { Struct } = VM.require("${REPL_DEVHUB}/widget/DevHub.modules.utils");
+
+if (!Struct) {
+ return Loading modules...
;
+}
+
+State.init({
+ handle: "",
+ name: "",
+ tag: "",
+ description: "",
+});
+
+const CommunityInputsPartialSchema = {
+ handle: {
+ inputProps: {
+ min: 2,
+ max: 40,
+
+ placeholder:
+ "Choose unique URL handle for your community. Example: zero-knowledge.",
+
+ required: true,
+ },
+
+ label: "URL handle",
+ order: 3,
+ },
+
+ name: {
+ inputProps: {
+ min: 2,
+ max: 30,
+ placeholder: "Community name.",
+ required: true,
+ },
+
+ label: "Name",
+ order: 1,
+ },
+
+ tag: {
+ inputProps: {
+ min: 2,
+ max: 30,
+
+ placeholder:
+ "Any posts with this tag will show up in your community feed.",
+
+ required: true,
+ },
+
+ label: "Tag",
+ order: 4,
+ },
+
+ description: {
+ inputProps: {
+ min: 2,
+ max: 60,
+
+ placeholder:
+ "Describe your community in one short sentence that will appear in the communities discovery page.",
+
+ required: true,
+ },
+
+ label: "Description",
+ order: 2,
+ },
+};
+
+const communityInputsValidator = (formValues) =>
+ Struct.typeMatch(formValues) &&
+ Object.values(formValues).every(
+ (value) => typeof value === "string" && value.length > 0
+ );
+
+const onCommunitySubmit = (inputs) =>
+ createCommunity({
+ inputs: {
+ ...inputs,
+
+ bio_markdown: [
+ "This is a sample text about your community.",
+ "You can change it on the community configuration page.",
+ ].join("\n"),
+
+ logo_url:
+ "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu",
+
+ banner_url:
+ "https://ipfs.near.social/ipfs/bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4",
+ },
+ });
+
+const [showSpawner, setShowSpawner] = useState(false);
+
+const CommunitySpawner = () => (
+ setShowSpawner(false),
+ }}
+ />
+ ),
+ }}
+ />
+);
+
+const communitiesMetadata = getAllCommunitiesMetadata();
+
+if (!communitiesMetadata) {
+ return Loading...
;
+}
+
+function CommunityCard({ format, isBannerEnabled, metadata }) {
+ const renderFormat =
+ format === "small" || format === "medium" ? format : "small";
+
+ const formatSmall = (
+
+
+
+
+
+
+
+ {metadata.name}
+
+
+
+ {metadata.description}
+
+
+
+
+
+ );
+
+ const formatMedium = (
+
+
+
+
+
{metadata.name}
+ {metadata.description}
+
+
+ );
+
+ return {
+ small: formatSmall,
+ medium: formatMedium,
+ }[renderFormat];
+}
+
+return (
+
+
+
+
+
+ Communities
+
+
+
+
+ Discover NEAR developer communities
+
+
+ {context.accountId && (
+
+ setShowSpawner(!showSpawner),
+ className: "btn btn-primary",
+ label: "Create Community",
+ }}
+ />
+
+ )}
+
+ {/* // TODO: Align centers */}
+
+ {showSpawner && }
+ {(communitiesMetadata ?? []).reverse().map((communityMetadata) => (
+
+ ))}
+
+
+);
diff --git a/src/DevHub/pages/community/configuration.jsx b/src/DevHub/pages/community/configuration.jsx
new file mode 100644
index 000000000..9b0d76214
--- /dev/null
+++ b/src/DevHub/pages/community/configuration.jsx
@@ -0,0 +1,272 @@
+const {
+ permissions,
+ handle,
+ community,
+ // communityAddonConfigs, Commenting out to reduce scope on root merge
+ // availableAddons,
+ deleteCommunity,
+ updateCommunity,
+} = props;
+
+const [communityData, setCommunityData] = useState(community);
+const [selectedAddon, setSelectedAddon] = useState(null);
+const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+
+const sectionSubmit = (sectionData) => {
+ console.log(sectionData);
+ const updatedCommunityData = {
+ ...Object.entries(sectionData).reduce(
+ (update, [propertyKey, propertyValue]) => ({
+ ...update,
+
+ [propertyKey]:
+ typeof propertyValue !== "string" || (propertyValue?.length ?? 0) > 0
+ ? propertyValue ?? null
+ : null,
+ }),
+
+ communityData
+ ),
+ };
+ setCommunityData(updatedCommunityData);
+ setHasUnsavedChanges(true);
+};
+
+const hasConfigurePermissions = true;
+// permissions.can_configure
+const hasDeletePermissions = true;
+// permissions.can_delete
+
+function CommunityAddonConfigurator({ addonConfig }) {
+ // TODO: Simplify this. Tile should be module.
+ const match = availableAddons.find((it) => it.id === addonConfig.addon_id);
+ return (
+
+ match ? (
+ console.log(v),
+ }}
+ />
+ ) : (
+ {"Unknown addon with addon ID: " + addon.id}
+ ),
+ }}
+ />
+ ),
+ }}
+ />
+ );
+}
+
+return (
+
+
+ ),
+ }}
+ />
+
(
+
+ ),
+ }}
+ />
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ ),
+ }}
+ />
+ (
+
+ ),
+ }}
+ />
+ ),
+ }}
+ />
+
+ ),
+ }}
+ />
+ {/*
+ {(communityAddonConfigs || []).map((addonConfig) => (
+ //
+ // ))}
+ {/* {hasConfigurePermissions && (
+
+ )} */}
+ {hasDeletePermissions && (
+
+ deleteCommunity({ handle }),
+ }}
+ />
+
+ )}
+ {hasConfigurePermissions && hasUnsavedChanges && (
+
+
+ updateCommunity({ handle, community: communityData }), // TODO : Track changes in State
+ }}
+ />
+
+ )}
+
+);
diff --git a/src/DevHub/pages/community/index.jsx b/src/DevHub/pages/community/index.jsx
new file mode 100644
index 000000000..a3b45368d
--- /dev/null
+++ b/src/DevHub/pages/community/index.jsx
@@ -0,0 +1,192 @@
+const Button = styled.button`
+ height: 40px;
+ font-size: 14px;
+ border-color: #e3e3e0;
+ background-color: #ffffff;
+`;
+
+const Banner = styled.div`
+ max-width: 100%;
+ min-height: 240px;
+ height: 240px;
+`;
+
+const CenteredMessage = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: ${(p) => p.height ?? "100%"};
+`;
+
+const NavUnderline = styled.ul`
+ border-bottom: 1px #eceef0 solid;
+ cursor: pointer;
+ a {
+ color: #687076;
+ text-decoration: none;
+ }
+
+ a.active {
+ font-weight: bold;
+ color: #0c7283;
+ border-bottom: 4px solid #0c7283;
+ }
+`;
+
+const { handle, tab, permissions, community } = props;
+
+const { href } = VM.require(
+ "${REPL_DEVHUB_CONTRACT}/widget/DevHub.modules.utils"
+);
+
+if (!href) {
+ return <>>;
+}
+
+if (!tab) {
+ tab = "Activity";
+}
+
+const [isLinkCopied, setLinkCopied] = useState(false);
+
+const tabs = [
+ {
+ title: "Activity",
+ iconClass: "bi bi-house-door",
+ view: "${REPL_DEVHUB}/widget/DevHub.entity.community.Activity",
+ params: {
+ handle,
+ },
+ },
+ {
+ title: "Teams",
+ iconClass: "bi bi-people-fill",
+ view: "${REPL_DEVHUB}/widget/DevHub.entity.community.Teams",
+ params: {
+ handle,
+ },
+ },
+];
+
+(community.addons || []).map((addon) => {
+ tabs.push({
+ id: addon.id,
+ title: addon.display_name,
+ iconClass: addon.icon,
+ view: "${REPL_DEVHUB}/widget/DevHub.pages.addon.index",
+ params: {
+ addon_id: addon.addon_id,
+ config: community.configs[addon.id],
+ },
+ });
+});
+
+const onShareClick = () =>
+ clipboard
+ .writeText(
+ href({
+ gateway: "near.org",
+ widgetSrc: "${REPL_DEVHUB}/widget/DevHub.App",
+ params: { page: "community", handle },
+ })
+ )
+ .then(setLinkCopied(true));
+
+let currentTab = tabs.find((it) => it.title === tab);
+
+return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {community.name}
+ {community.description}
+
+
+
+
+ {true && ( // TODO: Add back permissions check permissions.can_configure
+
+
+
+ )}
+
+ setLinkCopied(false),
+ title: "Copy link to clipboard",
+ }}
+ />
+
+
+
+ {tabs.map(
+ ({ title }) =>
+ title && (
+
+
+ {title}
+
+
+ )
+ )}
+
+
+ {currentTab && }
+
+
+);
diff --git a/src/DevHub/pages/feed.jsx b/src/DevHub/pages/feed.jsx
new file mode 100644
index 000000000..2688e2e39
--- /dev/null
+++ b/src/DevHub/pages/feed.jsx
@@ -0,0 +1,126 @@
+const { author, recency, tag } = props;
+
+const { getFeaturedCommunities } = VM.require(
+ "${REPL_DEVHUB}/widget/DevHub.modules.contract-sdk"
+);
+
+const { href } = VM.require("${REPL_DEVHUB}/widget/DevHub.modules.utils");
+
+if (!getFeaturedCommunities || !href) {
+ return Loading modules...
;
+}
+
+const Gradient = styled.div`
+ {
+ height: 250px;
+ text-align: center;
+ background: radial-gradient(
+ circle,
+ rgba(29, 55, 57, 1) 30%,
+ rgba(24, 24, 24, 1) 80%
+ );
+
+ font-family: Arial, sans-serif;
+ }
+
+ .text-primary-gradient {
+ color: #53fdca;
+ -webkit-text-fill-color: transparent;
+ background-image: linear-gradient(#8e76ba, #1ed2f0);
+ -webkit-background-clip: text;
+ background-clip: text;
+ }
+
+ .subtitle-above {
+ font-size: 18px;
+ letter-spacing: 1px;
+ font-family: Courier, monospace;
+ }
+
+ .subtitle-below {
+ font-size: 16px;
+ }
+
+ .slogan {
+ font-weight: 600;
+ font-size: 60px;
+ }
+`;
+
+const featuredCommunities = getFeaturedCommunities() || [];
+
+function Banner() {
+ return (
+
+
+
+ A decentralized community of
+
+
+
+ NEAR Developers
+
+
+
+ Share your ideas, match solutions, and access support and funding.
+
+
+
+
+
+
Featured Communities
+
+
+ {featuredCommunities.map((community) => (
+
+ ))}
+
+
+
+
Activity
+
+ );
+}
+
+const [searchTag, setSearchTag] = useState(tag || "");
+const [searchAuthor, setSearchAthor] = useState(author || "");
+
+const onTagSearch = (tag) => {
+ setSearchTag(tag);
+};
+
+const onAuthorSearch = (author) => {
+ setSearchAthor(author);
+};
+return (
+
+
+
+ ),
+ onAuthorSearch,
+ onTagSearch,
+ recency,
+ tag: searchTag,
+ tagQuery: { tag: searchTag },
+ transactionHashes: props.transactionHashes,
+ }}
+ />
+
+);