diff --git a/apps/commune-forum/eslint.config.js b/apps/commune-forum/eslint.config.js new file mode 100644 index 00000000..673f6f23 --- /dev/null +++ b/apps/commune-forum/eslint.config.js @@ -0,0 +1,14 @@ +import baseConfig, { restrictEnvAccess } from "@commune-ts/eslint-config/base"; +import nextjsConfig from "@commune-ts/eslint-config/nextjs"; +import reactConfig from "@commune-ts/eslint-config/react"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [".next/**"], + }, + ...baseConfig, + ...reactConfig, + ...nextjsConfig, + ...restrictEnvAccess, +]; diff --git a/apps/commune-forum/next-env.d.ts b/apps/commune-forum/next-env.d.ts new file mode 100644 index 00000000..40c3d680 --- /dev/null +++ b/apps/commune-forum/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/commune-forum/next.config.mjs b/apps/commune-forum/next.config.mjs new file mode 100644 index 00000000..6330221f --- /dev/null +++ b/apps/commune-forum/next.config.mjs @@ -0,0 +1,24 @@ +import { fileURLToPath } from "url"; +import createJiti from "jiti"; + +// Import env files to validate at build time. Use jiti so we can load .ts files in here. +createJiti(fileURLToPath(import.meta.url))("./src/env"); + +/** @type {import("next").NextConfig} */ +const config = { + reactStrictMode: true, + + /** Enables hot reloading for local packages without a build step */ + transpilePackages: [ + "@commune-ts/api", + "@commune-ts/db", + "@commune-ts/ui", + // "@commune-ts/validators", + ], + + /** We already do linting and typechecking as separate tasks in CI */ + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, +}; + +export default config; diff --git a/apps/commune-forum/package.json b/apps/commune-forum/package.json new file mode 100644 index 00000000..3f9bbb50 --- /dev/null +++ b/apps/commune-forum/package.json @@ -0,0 +1,50 @@ +{ + "name": "commune-forum", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "dev": "next dev --port 3009", + "lint": "eslint", + "start": "next start", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@commune-ts/api": "workspace:*", + "@commune-ts/db": "workspace:*", + "@commune-ts/providers": "workspace:*", + "@commune-ts/types": "workspace:*", + "@commune-ts/ui": "workspace:*", + "@commune-ts/utils": "workspace:*", + "@commune-ts/wallet": "workspace:*", + "@heroicons/react": "catalog:", + "@t3-oss/env-nextjs": "catalog:", + "@tanstack/react-query": "catalog:", + "@trpc/client": "catalog:", + "@trpc/react-query": "catalog:", + "@trpc/server": "catalog:", + "@uiw/react-markdown-preview": "^5.1.1", + "luxon": "^3.5.0", + "next": "catalog:", + "react": "catalog:react18", + "react-dom": "catalog:react18", + "rustie": "catalog:", + "tsafe": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@commune-ts/eslint-config": "workspace:*", + "@commune-ts/tailwind-config": "workspace:*", + "@commune-ts/tsconfig": "workspace:*", + "@next/eslint-plugin-next": "catalog:", + "@types/luxon": "^3.4.2", + "@types/node": "catalog:", + "@types/react": "catalog:react18", + "@types/react-dom": "catalog:react18", + "jiti": "catalog:", + "postcss": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:" + } +} \ No newline at end of file diff --git a/apps/commune-forum/postcss.config.cjs b/apps/commune-forum/postcss.config.cjs new file mode 100644 index 00000000..ee5f90b3 --- /dev/null +++ b/apps/commune-forum/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + tailwindcss: {}, + }, +}; diff --git a/apps/commune-forum/public/bg-pattern.svg b/apps/commune-forum/public/bg-pattern.svg new file mode 100644 index 00000000..0959c09f --- /dev/null +++ b/apps/commune-forum/public/bg-pattern.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/commune-forum/public/discord-icon.svg b/apps/commune-forum/public/discord-icon.svg new file mode 100644 index 00000000..856b256c --- /dev/null +++ b/apps/commune-forum/public/discord-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/commune-forum/public/docs-icon.svg b/apps/commune-forum/public/docs-icon.svg new file mode 100644 index 00000000..cdb10983 --- /dev/null +++ b/apps/commune-forum/public/docs-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/commune-forum/public/favicon.ico b/apps/commune-forum/public/favicon.ico new file mode 100644 index 00000000..fd58f037 Binary files /dev/null and b/apps/commune-forum/public/favicon.ico differ diff --git a/apps/commune-forum/public/github-icon.svg b/apps/commune-forum/public/github-icon.svg new file mode 100644 index 00000000..05090b57 --- /dev/null +++ b/apps/commune-forum/public/github-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/commune-forum/public/logo.svg b/apps/commune-forum/public/logo.svg new file mode 100644 index 00000000..498f4a42 --- /dev/null +++ b/apps/commune-forum/public/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/commune-forum/public/send-icon.svg b/apps/commune-forum/public/send-icon.svg new file mode 100644 index 00000000..b944e255 --- /dev/null +++ b/apps/commune-forum/public/send-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/commune-forum/public/stake-icon.svg b/apps/commune-forum/public/stake-icon.svg new file mode 100644 index 00000000..ecc9e1c1 --- /dev/null +++ b/apps/commune-forum/public/stake-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/commune-forum/public/telegram-icon.svg b/apps/commune-forum/public/telegram-icon.svg new file mode 100644 index 00000000..21f0275c --- /dev/null +++ b/apps/commune-forum/public/telegram-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/commune-forum/public/unstake-icon.svg b/apps/commune-forum/public/unstake-icon.svg new file mode 100644 index 00000000..444b5a5b --- /dev/null +++ b/apps/commune-forum/public/unstake-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/commune-forum/public/wallet-icon.svg b/apps/commune-forum/public/wallet-icon.svg new file mode 100644 index 00000000..a8d14072 --- /dev/null +++ b/apps/commune-forum/public/wallet-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/commune-forum/public/x-icon.svg b/apps/commune-forum/public/x-icon.svg new file mode 100644 index 00000000..e73730c5 --- /dev/null +++ b/apps/commune-forum/public/x-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/commune-forum/src/app/api/trpc/[trpc]/route.ts b/apps/commune-forum/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..9444bd88 --- /dev/null +++ b/apps/commune-forum/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,46 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +import { appRouter, createTRPCContext } from "@commune-ts/api"; + +import { env } from "~/env"; + +/** + * Configure basic CORS headers + */ +const setCorsHeaders = (res: Response) => { + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Request-Method", "*"); + res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST"); + res.headers.set("Access-Control-Allow-Headers", "*"); +}; + +export const OPTIONS = () => { + const response = new Response(null, { + status: 204, + }); + setCorsHeaders(response); + return response; +}; + +const handler = async (req: Request) => { + const response = await fetchRequestHandler({ + endpoint: "/api/trpc", + router: appRouter, + req, + createContext: () => + createTRPCContext({ + session: null, + headers: req.headers, + jwtSecret: env.JWT_SECRET, + authOrigin: env.AUTH_ORIGIN, + }), + onError({ error, path }) { + console.error(`>>> tRPC Error on '${path}'`, error); + }, + }); + + setCorsHeaders(response); + return response; +}; + +export { handler as GET, handler as POST }; diff --git a/apps/commune-forum/src/app/components/apps-list.tsx b/apps/commune-forum/src/app/components/apps-list.tsx new file mode 100644 index 00000000..6a63e75f --- /dev/null +++ b/apps/commune-forum/src/app/components/apps-list.tsx @@ -0,0 +1,40 @@ +import { links } from "@commune-ts/ui"; +import Link from "next/link"; + +const appsList = [ + { + title: "Landing page", + url: links.landing_page + }, + { + title: "Wallet", + url: links.wallet + }, + { + title: "Validator", + url: "#" + }, + { + title: "Governance", + url: links.governance + }, +] + +export default function AppsList() { + return ( +
+
+

Want to engage in the community?

+

Try our apps

+
+
+
+ {appsList.map((app) => ( + {app.title} + )) + } +
+
+
+ ); +} diff --git a/apps/commune-forum/src/app/components/categories-tag.tsx b/apps/commune-forum/src/app/components/categories-tag.tsx new file mode 100644 index 00000000..dcfc2b3f --- /dev/null +++ b/apps/commune-forum/src/app/components/categories-tag.tsx @@ -0,0 +1,34 @@ + +interface CategoryTagProps { + categoryName: string; + categoryId: number; + className?: string; +} + +export const CategoriesTag = (props: CategoryTagProps) => { + const { categoryName, categoryId, className } = props; + + const getCategoryColor = (categoryId: number) => { + const colors = { + 1: "bg-gray-400/5 text-gray-400 border-gray-400", + 2: "bg-orange-500/5 text-orange-500 border-orange-500", + 3: "bg-emerald-500/5 text-emerald-500 border-emerald-500", + 4: "bg-blue-500/5 text-blue-500 border-blue-500", + 5: "bg-pink-500/5 text-pink-500 border-pink-500", + 6: "bg-yellow-500/5 text-yellow-500 border-yellow-500", + } + return colors[categoryId as keyof typeof colors] || colors[1]; + } + + const renderCategoryTag = ({ categoryName, categoryId, className }: CategoryTagProps) => { + return ( + + {categoryName} + + ) + } + + return ( + renderCategoryTag({ categoryName, categoryId, className }) + ) +} \ No newline at end of file diff --git a/apps/commune-forum/src/app/components/comments/create-comment.tsx b/apps/commune-forum/src/app/components/comments/create-comment.tsx new file mode 100644 index 00000000..02511417 --- /dev/null +++ b/apps/commune-forum/src/app/components/comments/create-comment.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; + +import { useCommune } from "@commune-ts/providers/use-commune"; +import { toast } from "@commune-ts/providers/use-toast"; + +import { api } from "~/trpc/react"; + +const MAX_CHARACTERS = 300; +const MIN_STAKE_REQUIRED = 5000; + +export function CreateComment({ + postId, +}: { + postId: string; +}) { + const router = useRouter(); + const { selectedAccount } = useCommune(); + + const [content, setContent] = useState(""); + const [error, setError] = useState(null); + const [remainingChars, setRemainingChars] = useState(MAX_CHARACTERS); + + const utils = api.useUtils(); + const CreateComment = api.forum.createComment.useMutation({ + onSuccess: () => { + router.refresh(); + setContent(""); + setRemainingChars(MAX_CHARACTERS); + }, + }); + + useEffect(() => { + setRemainingChars(MAX_CHARACTERS - content.length); + }, [content]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!selectedAccount?.address) { + setError("Please connect your wallet to submit a comment."); + return; + } + + try { + // TODO: Validate if we need to check for the minimum balance required to comment + await CreateComment.mutateAsync({ + content, + postId: postId, + userKey: selectedAccount.address, + }); + toast.success("Comment submitted successfully!"); + await utils.forum.getCommentsByPost.invalidate({ postId }); + } catch (err) { + if (err instanceof z.ZodError) { + setError(err.errors[0]?.message ?? "Invalid input"); + } else { + setError("An unexpected error occurred. Please try again."); + } + } + }; + + + const isSubmitDisabled = () => { + if (CreateComment.isPending || !selectedAccount?.address) return true; + return false + }; + + return ( +
+
+

Create a Comment

+
+
+ + {errors.content && ( +

{errors.content}

+ )} +
+
+ +
+
+ + + )} + + ); +} diff --git a/apps/commune-forum/src/app/components/comments/view-comment.tsx b/apps/commune-forum/src/app/components/comments/view-comment.tsx new file mode 100644 index 00000000..bb69ce02 --- /dev/null +++ b/apps/commune-forum/src/app/components/comments/view-comment.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { + ChevronDoubleDownIcon, + ChevronDoubleUpIcon, + UserIcon, +} from "@heroicons/react/20/solid"; + +import { smallAddress } from "@commune-ts/utils"; + +import { api } from "~/trpc/react"; +import { ReportComment } from "./report-comment"; +import { DateTime } from "luxon"; + +export enum VoteType { + UP = "UP", + DOWN = "DOWN", +} + +export function ViewComment({ + postId, +}: { + postId: string; +}) { + const { + data: postComments, + isLoading, + } = api.forum.getCommentsByPost.useQuery( + { postId }, + { enabled: !!postId }, + ); + + const [sortBy, setSortBy] = useState<"newest" | "oldest" | "mostUpvotes">( + "oldest", + ); + + const sortedComments = postComments + ? [...postComments].sort((a, b) => { + if (sortBy === "newest") { + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } else { + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + } + }) + : []; + + return ( +
+
+
+

+ Community Comments +

+
+ {["oldest", "newest"].map((option) => ( + + ))} +
+
+ + {isLoading ? ( + <> +
+
+ + + + {" "} + Loading user address... + +
+ + +
+
+

Loading...

+
+ + ) : ( + <> + {sortedComments.length ? ( +
= 3 && "pr-2"}`} + > + {sortedComments.map((comment) => { + return ( +
+
+
+ By: {smallAddress(comment.userKey)}{" "} + + {DateTime.fromJSDate(comment.createdAt).toLocal().toRelative()} + +
+
+

{comment.content}

+
+ +
+
+ ) + })} +
+ ) : ( +

No comments yet

+ )} + + )} +
+
+ ); +} diff --git a/apps/commune-forum/src/app/components/create-external-post.tsx b/apps/commune-forum/src/app/components/create-external-post.tsx new file mode 100644 index 00000000..7988a054 --- /dev/null +++ b/apps/commune-forum/src/app/components/create-external-post.tsx @@ -0,0 +1,164 @@ +"use client" +import { useCallback, useState } from "react"; + +import { z } from "zod"; + +import { useCommune } from "@commune-ts/providers/use-commune"; +import { Checkbox } from "@commune-ts/ui"; + +import { api } from "~/trpc/react"; +import type { Category } from "./filters"; +import { CategoriesSelector } from "./filters"; +import { toast } from "@commune-ts/providers/use-toast"; + +interface FormErrors { + title?: string | null; + href?: string | null; +} + +interface CreatePostProps { + categories: Category[]; + handleModal: () => void; +} + +export const CreateExternalPost: React.FC = (props) => { + const { categories, handleModal } = props; + + const utils = api.useUtils(); + const { mutate: createPost, isPending } = api.forum.createPost.useMutation({ + onSuccess: async () => { + toast.success("Post created successfully"); + handleCleanStates() + await utils.forum.all.invalidate(); + handleModal(); + }, + }) + + const { isConnected, selectedAccount } = useCommune(); + + const [selectedCategory, setSelectedCategory] = useState(null); + const [title, setTitle] = useState(""); + const [href, setHref] = useState(""); + const [error, setError] = useState({ + title: null, + href: null, + }); + + + const [isAnon, setIsAnon] = useState(false); + + const handleCategoryChange = useCallback( + (category: Category | null) => { + if (category?.id === selectedCategory?.id) return + setSelectedCategory(category); + console.log(selectedCategory) + }, + [selectedCategory] + ); + + const handleIsAnonCheckbox = () => { + setIsAnon(!isAnon); + } + + function HandleSubmit(event: React.FormEvent): void { + event.preventDefault(); + setError({}); + + const postSchema = z.object({ + title: z.string().min(1, "Title is required"), + href: z.string().url(`Your URL must have the following format "https://www.example.com"`).min(1, "External post URL is required"), + }) + + const result = postSchema.safeParse({ + title, + href, + }); + + if (!result.success) { + const fieldErrors: FormErrors = {} + + result.error.errors.forEach((err) => { + if (err.path.length > 0) { + const fieldName = err.path[0] as keyof FormErrors; + fieldErrors[fieldName] = err.message; + } + }); + + setError(fieldErrors); + return; + } + + if (!selectedAccount?.address) return + + createPost({ + title, + href, + userKey: selectedAccount.address, + isAnonymous: isAnon, + categoryId: selectedCategory?.id ?? 1, + }) + } + + const handleCleanStates = () => { + setTitle(""); + setHref(""); + setIsAnon(false); + setSelectedCategory(null); + } + + return ( +
+
+
+
+ +
+
+
+
+ { + setTitle(e.target.value); + }} + placeholder="Your external post title here..." + type="text" + min={1} + value={title} + /> + {error.title && (

{error.title}

)} + { + setHref(e.target.value); + }} + placeholder="https://example.com" + value={href} + /> + {error.href && (

{error.href}

)} +
+
+
+ + +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/commune-forum/src/app/components/create-post-dropdown.tsx b/apps/commune-forum/src/app/components/create-post-dropdown.tsx new file mode 100644 index 00000000..2dfddb4a --- /dev/null +++ b/apps/commune-forum/src/app/components/create-post-dropdown.tsx @@ -0,0 +1,45 @@ +"use client"; +import React from 'react' +import { useState } from "react"; +import { Dropdown } from "./dropdown"; +import { Modal } from "./modal"; +import { CreatePost } from "./create-post"; +import type { Category } from './filters/categories-selector'; +import { CreateExternalPost } from './create-external-post'; +import { cairo } from '~/utils/fonts'; +interface FiltersProps { + categories: Category[]; +} + +export const CreatePostDropdown: React.FC = ({ categories, }) => { + const [openModal, setOpenModal] = useState<"EXTERNAL" | "INTERNAL" | null>(); + + const closeModal = () => { + setOpenModal(null); + }; + + const dropdownActionsList = [ + { + title: "Forum post", + handle: () => setOpenModal("INTERNAL"), + }, + { + title: "External post", + handle: () => setOpenModal("EXTERNAL"), + }, + ]; + + return ( + + ); +} diff --git a/apps/commune-forum/src/app/components/create-post.tsx b/apps/commune-forum/src/app/components/create-post.tsx new file mode 100644 index 00000000..b7fadf91 --- /dev/null +++ b/apps/commune-forum/src/app/components/create-post.tsx @@ -0,0 +1,178 @@ +"use client" +import { useCallback, useState } from "react"; +import MarkdownPreview from "@uiw/react-markdown-preview"; +import { z } from "zod"; + +import { useCommune } from "@commune-ts/providers/use-commune"; +import { Checkbox } from "@commune-ts/ui"; + +import { cairo } from "~/utils/fonts"; +import { api } from "~/trpc/react"; +import type { Category } from "./filters"; +import { CategoriesSelector } from "./filters"; +import { toast } from "@commune-ts/providers/use-toast"; + +const postSchema = z.object({ + title: z.string().min(1, "Title is required"), + content: z.string().min(1, "Content is required"), +}); + +interface CreatePostProps { + categories: Category[]; + handleModal: () => void; +} + +export const CreatePost: React.FC = (props) => { + const { categories, handleModal } = props; + + const utils = api.useUtils(); + const { mutate: createPost, isPending } = api.forum.createPost.useMutation({ + onSuccess: async () => { + toast.success("Post created successfully"); + handleCleanStates() + await utils.forum.all.invalidate(); + handleModal(); + }, + }) + + const { isConnected, selectedAccount } = useCommune(); + + const [selectedCategory, setSelectedCategory] = useState(null); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + + const [editMode, setEditMode] = useState(true); + const [isAnon, setIsAnon] = useState(false); + + function toggleEditMode(): void { + setEditMode(!editMode); + } + + const handleCategoryChange = useCallback( + (category: Category | null) => { + if (category?.id === selectedCategory?.id) return + setSelectedCategory(category); + console.log(selectedCategory) + }, + [selectedCategory] + ); + + const handleIsAnonCheckbox = () => { + setIsAnon(!isAnon); + } + + function HandleSubmit(event: React.FormEvent): void { + event.preventDefault(); + + const result = postSchema.safeParse({ + title, + content, + }); + if (!result.success || !selectedAccount?.address) return + + // TODO: Implement check for minimum balance required to create a post + + createPost({ + title, + content, + userKey: selectedAccount.address, + isAnonymous: isAnon, + categoryId: selectedCategory?.id ?? 1, + }) + } + + const handleCleanStates = () => { + setTitle(""); + setContent(""); + setIsAnon(false); + setSelectedCategory(null); + setEditMode(true); + } + return ( +
+
+
+
+ + +
+
+ +
+
+
+ {/* TODO: Take off this nested ternary statement */} + {editMode ? ( +
+ { + setTitle(e.target.value); + }} + placeholder="Your post title here..." + type="text" + value={title} + /> +