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 (
+
+ );
+}
diff --git a/apps/commune-forum/src/app/components/comments/report-comment.tsx b/apps/commune-forum/src/app/components/comments/report-comment.tsx
new file mode 100644
index 00000000..9133db35
--- /dev/null
+++ b/apps/commune-forum/src/app/components/comments/report-comment.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import type { inferProcedureOutput } from "@trpc/server";
+import { useState } from "react";
+import { XMarkIcon } from "@heroicons/react/16/solid";
+import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
+import { z } from "zod";
+
+import type { AppRouter } from "@commune-ts/api";
+import { toast } from "@commune-ts/providers/use-toast";
+
+import { api } from "~/trpc/react";
+
+type ProposalComment = inferProcedureOutput<
+ AppRouter["proposalComment"]["byReport"]
+>[0];
+
+interface ReportFormData {
+ reason: ProposalComment["reason"];
+ content: string;
+}
+
+interface ReportCommentProps {
+ commentId: string;
+}
+
+export function ReportComment({ commentId }: ReportCommentProps) {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const [formData, setFormData] = useState({
+ reason: "SPAM",
+ content: "",
+ });
+
+ const [errors, setErrors] = useState>({});
+
+ const reportCommentMutation =
+ api.proposalComment.createCommentReport.useMutation({
+ onSuccess: () => {
+ setModalOpen(false);
+ setFormData({ reason: formData.reason, content: "" });
+ setErrors({});
+ },
+ });
+
+ function toggleModalMenu() {
+ setModalOpen(!modalOpen);
+ }
+
+ const handleInputChange = (
+ e: React.ChangeEvent,
+ ) => {
+ const { name, value } = e.target;
+ if (name === "reason") {
+ setFormData((prev) => ({
+ ...prev,
+ reason: value as ProposalComment["reason"],
+ }));
+ } else {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ }
+ };
+
+ const validateForm = (): boolean => {
+ try {
+ setErrors({});
+ return true;
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const fieldErrors = error.formErrors
+ .fieldErrors as Partial;
+ setErrors(fieldErrors);
+ } else {
+ console.error("Unexpected error during form validation:", error);
+ }
+ return false;
+ }
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (validateForm()) {
+ reportCommentMutation.mutate({
+ commentId,
+ reason: formData.reason,
+ content: formData.content,
+ });
+
+ toast.success("Comment reported successfully.");
+ }
+ };
+
+ return (
+ <>
+
+ {modalOpen && (
+
+
+
+
+
+ Report Comment
+
+
+
+
+
+
+ )}
+ >
+ );
+}
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 (
+
+ );
+}
\ 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 (
+
+
+
+ {openModal === "INTERNAL" && }
+ {openModal === "EXTERNAL" && }
+
+
+ );
+}
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 (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/components/dropdown.tsx b/apps/commune-forum/src/app/components/dropdown.tsx
new file mode 100644
index 00000000..687d1214
--- /dev/null
+++ b/apps/commune-forum/src/app/components/dropdown.tsx
@@ -0,0 +1,49 @@
+import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
+import {
+ ChevronDownIcon,
+} from '@heroicons/react/20/solid'
+
+interface DropdownButton {
+ title: string | JSX.Element,
+ actionsList: {
+ title: string,
+ handle: () => void
+ }[]
+}
+
+export const Dropdown = (props: DropdownButton) => {
+ const { actionsList, title } = props;
+
+ return (
+
+ )
+}
diff --git a/apps/commune-forum/src/app/components/filters/categories-selector.tsx b/apps/commune-forum/src/app/components/filters/categories-selector.tsx
new file mode 100644
index 00000000..854833dd
--- /dev/null
+++ b/apps/commune-forum/src/app/components/filters/categories-selector.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useMemo, useCallback } from "react";
+import {
+ Listbox,
+ ListboxButton,
+ ListboxOption,
+ ListboxOptions,
+} from "@headlessui/react";
+import {
+ CheckIcon,
+ ChevronUpDownIcon,
+ XMarkIcon,
+} from "@heroicons/react/20/solid";
+import React from "react";
+
+export interface Category {
+ id: number | null;
+ name: string;
+}
+
+interface CategoriesSelectorProps {
+ categories: Category[];
+ selectedCategory: Category | null;
+ onCategoryChange: (category: Category | null) => void;
+ defaultCategoryName?: string;
+}
+
+const BUTTON_BASE_CLASSES =
+ "relative w-full cursor-default bg-white/5 text-center sm:text-left py-1 pl-3 pr-3 sm:pr-10 text-left border sm:text-sm sm:leading-6 hover:border-green-500 hover:cursor-pointer hover:text-green-500";
+const OPTIONS_CLASSES =
+ "absolute z-10 mt-1 max-h-60 w-full overflow-auto bg-white/5 backdrop-blur-md p-0.5 border border-white/20 text-base shadow-lg data-[closed]:data-[leave]:opacity-0 data-[leave]:transition data-[leave]:duration-100 data-[leave]:ease-in sm:text-sm";
+const OPTION_CLASSES =
+ "group relative cursor-default select-none py-2 pl-3 pr-9 text-white hover:cursor-pointer data-[focus]:bg-white/5 data-[focus]:text-green-500";
+
+export const CategoriesSelector: React.FC = React.memo(
+ ({ categories, selectedCategory, onCategoryChange, defaultCategoryName }) => {
+
+ const overrideCategories = useMemo(() => {
+ return [{ id: null, name: defaultCategoryName ?? "ALL" }, ...categories];
+ }, [categories, defaultCategoryName]);
+
+ const buttonClasses = useMemo(
+ () =>
+ [
+ BUTTON_BASE_CLASSES,
+ selectedCategory ? "border-green-500 text-green-500" : "border-white/20",
+ ].join(" "),
+ [selectedCategory]
+ );
+
+ const handleClearSelection = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onCategoryChange(null);
+ },
+ [onCategoryChange]
+ );
+
+ return (
+
+
+
+
+ {selectedCategory?.name ?? overrideCategories[0]?.name}
+
+
+ {selectedCategory ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {overrideCategories.map((category) => (
+
+ {({ selected }) => (
+ <>
+
+ {category.name}
+
+ {selected && category.id !== null && (
+
+
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+
+ );
+ }
+);
diff --git a/apps/commune-forum/src/app/components/filters/filters.tsx b/apps/commune-forum/src/app/components/filters/filters.tsx
new file mode 100644
index 00000000..a70a1bbb
--- /dev/null
+++ b/apps/commune-forum/src/app/components/filters/filters.tsx
@@ -0,0 +1,115 @@
+"use client"
+
+import React, { useEffect, useState, useCallback } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { ArrowLongDownIcon } from "@heroicons/react/16/solid";
+import type { Category } from "./categories-selector";
+import { CategoriesSelector } from "./categories-selector";
+import { cairo } from "~/utils/fonts";
+
+type SortField = "createdAt" | "upvotes";
+type SortOrder = "asc" | "desc";
+interface RenderSortersProps {
+ sortFieldLabels: Record;
+ handleSortChange: (field: SortField) => void;
+ sortField: SortField;
+ orderStyles: Record;
+ sortOrder: SortOrder;
+}
+
+const sortFieldLabels: Record = {
+ createdAt: "Date",
+ upvotes: "Upvotes",
+};
+
+const orderStyles: Record = {
+ asc: "-rotate-180",
+ desc: "rotate-0"
+};
+
+const RenderSorters: React.FC = React.memo(({ sortFieldLabels, handleSortChange, sortField, orderStyles, sortOrder }) => {
+ return (
+ <>
+ {Object.entries(sortFieldLabels).map(([field, label]) => (
+
+ ))}
+ >
+ )
+});
+
+interface FiltersProps {
+ categories: Category[];
+}
+
+export const Filters: React.FC = ({ categories }) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ const [sortField, setSortField] = useState(
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (searchParams.get("sortBy") as SortField) || "createdAt"
+ );
+ const [sortOrder, setSortOrder] = useState(
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (searchParams.get("order") as SortOrder) || "desc"
+ );
+
+ useEffect(() => {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("sortBy", sortField);
+ newSearchParams.set("order", sortOrder);
+ if (selectedCategory?.id) {
+ newSearchParams.set("categoryId", String(selectedCategory.id));
+ } else {
+ newSearchParams.delete("categoryId");
+ }
+ router.push(`?${newSearchParams.toString()}`, { scroll: false });
+ }, [sortField, sortOrder, selectedCategory?.id, router, searchParams]);
+
+ const handleSortChange = useCallback((field: SortField) => {
+ if (field === sortField) {
+ setSortOrder(prevOrder => (prevOrder === "asc" ? "desc" : "asc"));
+ } else {
+ setSortField(field);
+ setSortOrder("desc");
+ }
+ }, [sortField]);
+
+ const handleCategoryChange = useCallback(
+ (category: Category | null) => {
+ if (category?.id === selectedCategory?.id) return
+ setSelectedCategory(category);
+ },
+ [selectedCategory?.id]
+ );
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/commune-forum/src/app/components/filters/index.tsx b/apps/commune-forum/src/app/components/filters/index.tsx
new file mode 100644
index 00000000..fb4f16da
--- /dev/null
+++ b/apps/commune-forum/src/app/components/filters/index.tsx
@@ -0,0 +1,3 @@
+export { Filters } from "./filters"
+export { CategoriesSelector } from "./categories-selector"
+export type { Category } from "./categories-selector"
diff --git a/apps/commune-forum/src/app/components/modal.tsx b/apps/commune-forum/src/app/components/modal.tsx
new file mode 100644
index 00000000..3ed88713
--- /dev/null
+++ b/apps/commune-forum/src/app/components/modal.tsx
@@ -0,0 +1,48 @@
+"use client"
+import { XMarkIcon } from "@heroicons/react/20/solid";
+
+export const Modal = (props: { title: string, children: React.ReactNode, handleModal: () => void, modalOpen: boolean }) => {
+ const { title, handleModal, modalOpen, children } = props;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+ {/* Modal Header */}
+
+
+
+ {title}
+
+
+
+
+
+
+ {/* Modal Body */}
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/apps/commune-forum/src/app/components/post-item-list.tsx b/apps/commune-forum/src/app/components/post-item-list.tsx
new file mode 100644
index 00000000..e11b665b
--- /dev/null
+++ b/apps/commune-forum/src/app/components/post-item-list.tsx
@@ -0,0 +1,110 @@
+import Link from "next/link";
+import { VotesDisplay } from "./votes-display";
+import { ChatBubbleLeftRightIcon, EyeIcon } from "@heroicons/react/16/solid";
+import { smallAddress } from "@commune-ts/utils";
+import { cairo } from "~/utils/fonts";
+import { DateTime } from "luxon";
+import { CategoriesTag } from "./categories-tag";
+
+function formatInteractionNumbers(num: number): string {
+ const absNum = Math.abs(num);
+ const sign = num < 0 ? '-' : '';
+
+ if (absNum < 1000) {
+ return sign + absNum.toString();
+ }
+
+ const exp = Math.floor(Math.log10(absNum) / 3);
+ const scale = Math.pow(10, exp * 3);
+ const scaled = absNum / scale;
+
+ let formatted: string;
+ if (scaled < 10) {
+ formatted = scaled.toFixed(1).replace(/\.0$/, '');
+ } else {
+ formatted = Math.floor(scaled).toString();
+ }
+
+ return sign + formatted + 'KMB'[exp - 1];
+}
+
+interface PostItemProps {
+ post: {
+ id: string;
+ title: string;
+ userKey: string;
+ isAnonymous: boolean;
+ categoryName: string | null;
+ href: string | null;
+ createdAt: Date;
+ downvotes: number;
+ upvotes: number;
+ categoryId: number;
+ isPinned: boolean;
+ commentCount: number;
+ viewCount: number | null;
+ } | undefined
+}
+
+export const PostItem: React.FC = (props) => {
+ const { post } = props
+
+ if (!post) return null
+ const formattedDate = DateTime.fromJSDate(post.createdAt).toLocal().toRelative()
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ #{post.id.slice(0, 4)}
+
+
+ {post.title}
+
+
+
+
+
+ By:
+
+ {post.isAnonymous ? "Anonymous" : smallAddress(post.userKey)}
+
+
+
-
+
{formattedDate}
+
+
+
+
+
+
+
+
+
+
+
+ {!post.commentCount ? formatInteractionNumbers(post.commentCount) : 0}
+
+
+
+ {post.viewCount ? formatInteractionNumbers(post.viewCount) : 0}
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/apps/commune-forum/src/app/components/post-list.tsx b/apps/commune-forum/src/app/components/post-list.tsx
new file mode 100644
index 00000000..0b120d3b
--- /dev/null
+++ b/apps/commune-forum/src/app/components/post-list.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { useMemo } from "react";
+import { api } from "~/trpc/react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { cairo } from "~/utils/fonts";
+import { PostItem } from "./post-item-list";
+import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationEllipsis, PaginationNext } from "@commune-ts/ui";
+
+const sorters = {
+ createdAt: "createdAt",
+ upvotes: "upvotes",
+} as const;
+
+type SortBy = keyof typeof sorters;
+
+export const PostList: React.FC = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const sortOrder = useMemo(() => {
+ const order = searchParams.get("order")?.toUpperCase();
+ return order === "ASC" || order === "DESC" ? order : "DESC";
+ }, [searchParams]);
+
+ const sortBy = useMemo(() => {
+ const sortByParam = searchParams.get("sortBy");
+ return sortByParam && sortByParam in sorters
+ ? (sortByParam as SortBy)
+ : "createdAt";
+ }, [searchParams]);
+
+ const currentPage = useMemo(() => {
+ const page = searchParams.get("page");
+ return page ? Number(page) : 1;
+ }, [searchParams]);
+
+ const categoryId = useMemo(() => {
+ const id = searchParams.get("categoryId");
+ return id ? Number(id) : null;
+ }, [searchParams]);
+
+ const { data: posts, isFetching } = api.forum.all.useQuery(
+ {
+ sortOrder,
+ sortBy,
+ categoryId: categoryId,
+ page: currentPage,
+ pageSize: 5,
+ },
+ );
+
+ const unpinnedPosts = posts?.posts.filter((post) => !post.isPinned) ?? [];
+ const pinnedPosts = posts?.posts.filter((post) => post.isPinned) ?? [];
+
+ const hasDivider = (pinnedPosts.length > 0) && (unpinnedPosts.length > 0);
+
+ const totalPages = posts?.totalPages ?? 0;
+
+ const pageNumbers = useMemo(() => {
+ const numbers = [];
+ const range = 2;
+
+ for (let i = currentPage - range; i <= currentPage + range; i++) {
+ if (i > 0 && i <= totalPages) {
+ numbers.push(i);
+ }
+ }
+
+ return numbers;
+ }, [currentPage, totalPages]);
+
+ const renderPinnedPosts = () => {
+ if (pinnedPosts.length === 0) return null;
+ return (
+
+
Pinned posts
+ {pinnedPosts.map((post) => {
+ return (
+
+ )
+ })}
+
)
+ }
+
+ const renderUnpinnedPosts = () => {
+ if (unpinnedPosts.length === 0) return null;
+ return (
+
+ {unpinnedPosts.map((post) => {
+ return (
+
+ )
+ })}
+
)
+ }
+
+ return (
+
+ {renderPinnedPosts()}
+ {hasDivider &&
}
+ {renderUnpinnedPosts()}
+
+ {isFetching && (
+
+ )}
+
+ {!posts || (unpinnedPosts.length === 0 && pinnedPosts.length === 0) && (
+
+ )}
+
+
+
+
+ {/* Previous Button */}
+ {currentPage > 1 && (
+
+ router.push(`?page=${currentPage - 1}`)}
+ aria-label="Previous page"
+ />
+
+ )}
+
+ {/* First Page */}
+ {currentPage > 3 && (
+ <>
+
+ router.push(`?page=1`)}
+ aria-label="Go to page 1"
+ >
+ 1
+
+
+ {/* Ellipsis */}
+ {currentPage > 4 && (
+
+
+
+ )}
+ >
+ )}
+
+ {/* Page Numbers */}
+ {pageNumbers.map((pageNumber) => (
+
+ {pageNumber === currentPage ? (
+
+ {pageNumber}
+
+ ) : (
+ router.push(`?page=${pageNumber}`)}
+ aria-label={`Go to page ${pageNumber}`}
+ >
+ {pageNumber}
+
+ )}
+
+ ))}
+
+ {/* Last Page */}
+ {currentPage < totalPages - 2 && (
+ <>
+ {/* Ellipsis */}
+ {currentPage < totalPages - 3 && (
+
+
+
+ )}
+
+ router.push(`?page=${totalPages}`)}
+ aria-label={`Go to page ${totalPages}`}
+ >
+ {totalPages}
+
+
+ >
+ )}
+
+ {/* Next Button */}
+ {currentPage < totalPages && (
+
+ router.push(`?page=${currentPage + 1}`)}
+ aria-label="Next page"
+ />
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/commune-forum/src/app/components/section-header-text.tsx b/apps/commune-forum/src/app/components/section-header-text.tsx
new file mode 100644
index 00000000..ab8743f5
--- /dev/null
+++ b/apps/commune-forum/src/app/components/section-header-text.tsx
@@ -0,0 +1,7 @@
+export function SectionHeaderText({ text }: { text: string }) {
+ return (
+
+
{text}
+
+ );
+}
diff --git a/apps/commune-forum/src/app/components/votes-display.tsx b/apps/commune-forum/src/app/components/votes-display.tsx
new file mode 100644
index 00000000..e2f283e4
--- /dev/null
+++ b/apps/commune-forum/src/app/components/votes-display.tsx
@@ -0,0 +1,16 @@
+import { ChevronDoubleUpIcon } from "@heroicons/react/16/solid";
+
+export const VotesDisplay = ({ upVotes, downVotes, className }: { upVotes?: string | number, downVotes?: string | number, className?: string }) => {
+ return (
+
+
+
+ {upVotes ?? "-"}
+
+
+ {downVotes ?? "-"}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/layout.tsx b/apps/commune-forum/src/app/layout.tsx
new file mode 100644
index 00000000..f0adbc98
--- /dev/null
+++ b/apps/commune-forum/src/app/layout.tsx
@@ -0,0 +1,51 @@
+import "../styles/globals.css";
+
+import type { Metadata } from "next";
+
+import { Providers } from "@commune-ts/providers/context";
+import { links } from "@commune-ts/ui/data";
+import { Footer } from "@commune-ts/ui/footer";
+import { Header } from "@commune-ts/ui/header";
+import { Wallet, WalletButton } from "@commune-ts/wallet";
+
+import { TRPCReactProvider } from "~/trpc/react";
+import { cairo, oxanium } from "~/utils/fonts";
+
+export const metadata: Metadata = {
+ robots: "all",
+ title: "Community Forum",
+ icons: [{ rel: "icon", url: "favicon.ico" }],
+ description: "Making decentralized AI for everyone",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}): JSX.Element {
+ return (
+
+
+
+
+
+ }
+ navigationLinks={[
+ { name: "Homepage", href: links.landing_page, external: true },
+ { name: "Join Community", href: links.discord, external: true },
+ ]}
+ />
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/apps/commune-forum/src/app/loader.tsx b/apps/commune-forum/src/app/loader.tsx
new file mode 100644
index 00000000..c705a830
--- /dev/null
+++ b/apps/commune-forum/src/app/loader.tsx
@@ -0,0 +1,10 @@
+import { ArrowPathIcon } from "@heroicons/react/16/solid";
+
+export default function Loader() {
+ return (
+
+ );
+}
diff --git a/apps/commune-forum/src/app/page.tsx b/apps/commune-forum/src/app/page.tsx
new file mode 100644
index 00000000..e5b5a2a1
--- /dev/null
+++ b/apps/commune-forum/src/app/page.tsx
@@ -0,0 +1,22 @@
+import { Filters } from "./components/filters";
+import { CreatePostDropdown } from "./components/create-post-dropdown";
+import { PostList } from "./components/post-list";
+import AppsList from "./components/apps-list";
+import { api } from "~/trpc/server";
+
+export default async function HomePage() {
+ const categories = await api.forum.getCategories();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/post/[id]/_components/date.tsx b/apps/commune-forum/src/app/post/[id]/_components/date.tsx
new file mode 100644
index 00000000..63e4e652
--- /dev/null
+++ b/apps/commune-forum/src/app/post/[id]/_components/date.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import { DateTime } from "luxon"
+import { useEffect, useState } from "react"
+
+export const PostDate = (props: { date: Date, className: string }) => {
+ const { date, className } = props
+ const [formattedDate, setFormattedDate] = useState('Loading...')
+
+ useEffect(() => {
+ setFormattedDate(DateTime.fromJSDate(date).toFormat(" LLL dd, yyyy HH:mm"))
+ }, [date])
+
+ return (
+
+ {formattedDate}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/post/[id]/_components/post.tsx b/apps/commune-forum/src/app/post/[id]/_components/post.tsx
new file mode 100644
index 00000000..fa61db6e
--- /dev/null
+++ b/apps/commune-forum/src/app/post/[id]/_components/post.tsx
@@ -0,0 +1,77 @@
+
+import { MarkdownView } from "@commune-ts/ui/markdown-view";
+import {
+ removeEmojis,
+ smallAddress,
+} from "@commune-ts/utils";
+import { ArrowTopRightOnSquareIcon } from "@heroicons/react/16/solid";
+
+import Link from "next/link";
+
+import { SectionHeaderText } from "~/app/components/section-header-text";
+import { api } from "~/trpc/server";
+import { PostDate } from "./date";
+import { CreateComment } from "~/app/components/comments/create-comment";
+import { ViewComment } from "~/app/components/comments/view-comment";
+import { VotePostButton } from "./vote-post-button";
+
+export async function Post(props: { postId: string }): Promise {
+ const { postId } = props;
+ const post = await api.forum.byId({ id: postId });
+
+ if (!post) {
+ return (
+
+
No post found
+
+ )
+ }
+
+ await api.forum.incrementViewCount({ postId });
+
+ return (
+
+
+ {post.href && (
+
+
+
+ Post Link
+
+
({post.href})
+
+ )}
+ {/* TODO: Bring number of views to the post page */}
+ {post.content && (
+
+
+
ID: {smallAddress(post.id, 3)}
+
+
{post.title}
+
+
+ By: {!post.isAnonymous ? smallAddress(post.userKey, 3) : "Anonymous"}
+ -
+
+
+
+
+ {post.content && }
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/post/[id]/_components/vote-post-button.tsx b/apps/commune-forum/src/app/post/[id]/_components/vote-post-button.tsx
new file mode 100644
index 00000000..3b271a13
--- /dev/null
+++ b/apps/commune-forum/src/app/post/[id]/_components/vote-post-button.tsx
@@ -0,0 +1,145 @@
+"use client"
+import { useCommune } from "@commune-ts/providers/use-commune";
+import { ChevronDoubleUpIcon } from "@heroicons/react/16/solid";
+import { useEffect, useMemo, useState } from "react";
+import { api } from "~/trpc/react";
+
+interface VotePostProps {
+ postId: string;
+ className?: string;
+ upvotes: number;
+ downvotes: number;
+}
+
+export const VotePostButton = (post: VotePostProps) => {
+ const { postId, className } = post;
+ const { selectedAccount } = useCommune();
+
+ const { data: postVotes, refetch: postVotesRefetch } = api.forum.getPostVotesByPostId.useQuery(
+ { postId },
+ { enabled: !!postId }
+ );
+
+ const { upvotes, downvotes } = useMemo(() => {
+ if (!postVotes) return { upvotes: 0, downvotes: 0 };
+
+ return postVotes.reduce(
+ (acc, vote) => {
+ if (vote.voteType === "UPVOTE") {
+ acc.upvotes++;
+ } else {
+ acc.downvotes++;
+ }
+ return acc;
+ },
+ { upvotes: 0, downvotes: 0 }
+ );
+ }, [postVotes]);
+
+ const [localUpvotes, setLocalUpvotes] = useState(0);
+ const [localDownvotes, setLocalDownvotes] = useState(0);
+
+ useEffect(() => {
+ setLocalUpvotes(upvotes);
+ setLocalDownvotes(downvotes);
+ }, [upvotes, downvotes]);
+
+ const [localUserHadVote, setLocalUserHadVote] = useState<"UPVOTE" | "DOWNVOTE" | null>(null);
+
+ const utils = api.useUtils();
+
+ const {
+ data: userVotes,
+ refetch: refetchUserVotes,
+ } = api.forum.getPostVotesByUserId.useQuery(
+ { userKey: selectedAccount?.address ?? "" },
+ { enabled: !!selectedAccount }
+ );
+
+ const VotePostMutation = api.forum.votePost.useMutation();
+ const userHadVoted = useMemo(() => userVotes?.find((vote) => vote.postId === postId)?.voteType ?? null, [userVotes, postId]);
+
+ useEffect(() => {
+ setLocalUserHadVote(userHadVoted);
+ }, [userHadVoted]);
+
+ const adjustVoteCount = (type: "UPVOTE" | "DOWNVOTE", delta: number) => {
+ if (type === "UPVOTE") {
+ setLocalUpvotes((prev) => prev + delta);
+ } else {
+ setLocalDownvotes((prev) => prev + delta);
+ }
+ };
+
+
+ if (!selectedAccount) {
+ return (
+
+
Sign in with your wallet to vote
+
+ );
+ }
+
+ const handleVote = async (voteType: "DOWNVOTE" | "UPVOTE") => {
+ try {
+ await VotePostMutation.mutateAsync({
+ postId,
+ voteType,
+ userKey: selectedAccount.address,
+ });
+
+ if (localUserHadVote === voteType) {
+ // User is unvoting (removing their vote)
+ adjustVoteCount(voteType, -1);
+ setLocalUserHadVote(null);
+ } else {
+ // User is voting or changing their vote
+ if (localUserHadVote) {
+ // Decrease count for previous vote
+ adjustVoteCount(localUserHadVote, -1);
+ }
+ // Increase count for new vote
+ adjustVoteCount(voteType, +1);
+ setLocalUserHadVote(voteType);
+ }
+
+ await postVotesRefetch();
+ await refetchUserVotes();
+ await utils.forum.all.invalidate();
+ } catch (error) {
+ console.error("Failed to create vote:", error);
+ }
+ };
+
+ console.log(localUserHadVote, 'localUserHadVote');
+
+ return (
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/apps/commune-forum/src/app/post/[id]/loader.tsx b/apps/commune-forum/src/app/post/[id]/loader.tsx
new file mode 100644
index 00000000..c705a830
--- /dev/null
+++ b/apps/commune-forum/src/app/post/[id]/loader.tsx
@@ -0,0 +1,10 @@
+import { ArrowPathIcon } from "@heroicons/react/16/solid";
+
+export default function Loader() {
+ return (
+
+ );
+}
diff --git a/apps/commune-forum/src/app/post/[id]/page.tsx b/apps/commune-forum/src/app/post/[id]/page.tsx
new file mode 100644
index 00000000..a8832720
--- /dev/null
+++ b/apps/commune-forum/src/app/post/[id]/page.tsx
@@ -0,0 +1,29 @@
+import Link from "next/link";
+import { ArrowLeftIcon } from "@heroicons/react/20/solid";
+
+import { Post } from "./_components/post";
+
+export default function ViewPostPage({
+ params,
+}: {
+ params: { id: string };
+}): JSX.Element {
+ if (!params.id) {
+ return Not Found
;
+ }
+
+ return (
+
+
+
+ Go Back to Posts List
+
+
+
+ );
+}
diff --git a/apps/commune-forum/src/env.ts b/apps/commune-forum/src/env.ts
new file mode 100644
index 00000000..ce10bed3
--- /dev/null
+++ b/apps/commune-forum/src/env.ts
@@ -0,0 +1,42 @@
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+const AUTH_ORIGIN_DEFAULT = "forum.communeai.org";
+
+export const env = createEnv({
+ shared: {
+ NODE_ENV: z
+ .enum(["development", "production", "test"])
+ .default("development"),
+ },
+ /**
+ * Specify your server-side environment variables schema here.
+ * This way you can ensure the app isn't built with invalid env vars.
+ */
+ server: {
+ JWT_SECRET: z.string().min(8), // Secret used to sign the JWT
+ AUTH_ORIGIN: z.string().default(AUTH_ORIGIN_DEFAULT), // Origin URI used in the statement signed by the user to authenticate
+ PINATA_JWT: z.string(),
+ POSTGRES_URL: z.string().url(),
+ },
+ /**
+ * Specify your client-side environment variables schema here.
+ * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
+ */
+ client: {
+ NEXT_PUBLIC_AUTH_ORIGIN: z.string().default(AUTH_ORIGIN_DEFAULT), // Origin URI used in the statement signed by the user to authenticate
+ NEXT_PUBLIC_WS_PROVIDER_URL: z.string().url(),
+ NEXT_PUBLIC_CACHE_PROVIDER_URL: z.string().url(),
+ },
+ /**
+ * Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
+ */
+ experimental__runtimeEnv: {
+ NODE_ENV: process.env.NODE_ENV,
+ NEXT_PUBLIC_AUTH_ORIGIN: process.env.NEXT_PUBLIC_AUTH_ORIGIN,
+ NEXT_PUBLIC_WS_PROVIDER_URL: process.env.NEXT_PUBLIC_WS_PROVIDER_URL,
+ NEXT_PUBLIC_CACHE_PROVIDER_URL: process.env.NEXT_PUBLIC_CACHE_PROVIDER_URL,
+ },
+ skipValidation:
+ !!process.env.CI || process.env.npm_lifecycle_event === "lint",
+});
diff --git a/apps/commune-forum/src/styles/globals.css b/apps/commune-forum/src/styles/globals.css
new file mode 100644
index 00000000..151db0f2
--- /dev/null
+++ b/apps/commune-forum/src/styles/globals.css
@@ -0,0 +1,5 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@import url("../../../../packages/ui/src/components/scrollbar-style.css");
diff --git a/apps/commune-forum/src/trpc/react.tsx b/apps/commune-forum/src/trpc/react.tsx
new file mode 100644
index 00000000..3e1af590
--- /dev/null
+++ b/apps/commune-forum/src/trpc/react.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { httpBatchLink, loggerLink } from "@trpc/client";
+import { createTRPCReact } from "@trpc/react-query";
+import SuperJSON from "superjson";
+
+import type { AppRouter } from "@commune-ts/api";
+import { createAuthLink, makeAuthenticateUserFn } from "@commune-ts/api/client";
+import { useCommune } from "@commune-ts/providers/use-commune";
+
+import { env } from "~/env";
+
+const createQueryClient = () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30 * 1000,
+ },
+ },
+ });
+
+let clientQueryClientSingleton: QueryClient | undefined;
+const getQueryClient = () => {
+ if (typeof window === "undefined") {
+ return createQueryClient();
+ } else {
+ if (!clientQueryClientSingleton) {
+ clientQueryClientSingleton = createQueryClient();
+ }
+ return clientQueryClientSingleton;
+ }
+};
+
+export const api = createTRPCReact();
+
+export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
+ const queryClient = getQueryClient();
+
+ const { signHex } = useCommune();
+
+ const getStoredAuthorization = () => localStorage.getItem("authorization");
+ const setStoredAuthorization = (authorization: string) =>
+ localStorage.setItem("authorization", authorization);
+
+ const authenticateUser = makeAuthenticateUserFn(
+ getBaseUrl(),
+ env.NEXT_PUBLIC_AUTH_ORIGIN,
+ setStoredAuthorization,
+ signHex,
+ );
+
+ const trpcClient = api.createClient({
+ links: [
+ createAuthLink(authenticateUser, getStoredAuthorization),
+ loggerLink({
+ enabled: (op) =>
+ env.NODE_ENV === "development" ||
+ (op.direction === "down" && op.result instanceof Error),
+ }),
+ httpBatchLink({
+ url: getBaseUrl() + "/api/trpc",
+ headers() {
+ const headers: Record = {};
+ headers["x-trpc-source"] = "nextjs-react";
+ const authorization = localStorage.getItem("authorization");
+ if (authorization) {
+ headers.authorization = authorization;
+ }
+ return headers;
+ },
+ transformer: SuperJSON,
+ }),
+ ],
+ });
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+const getBaseUrl = () => {
+ if (typeof window !== "undefined") return window.location.origin;
+ // eslint-disable-next-line no-restricted-properties
+ return `http://localhost:${process.env.PORT ?? 3000}`;
+};
diff --git a/apps/commune-forum/src/trpc/server.ts b/apps/commune-forum/src/trpc/server.ts
new file mode 100644
index 00000000..a0b23bbb
--- /dev/null
+++ b/apps/commune-forum/src/trpc/server.ts
@@ -0,0 +1,20 @@
+import { cache } from "react";
+import { headers } from "next/headers";
+
+import { createCaller, createTRPCContext } from "@commune-ts/api";
+
+/**
+ * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
+ * handling a tRPC call from a React Server Component.
+ */
+const createContext = cache(() => {
+ const heads = new Headers(headers());
+ heads.set("x-trpc-source", "rsc");
+
+ return createTRPCContext({
+ session: null,
+ headers: heads,
+ });
+});
+
+export const api = createCaller(createContext);
diff --git a/apps/commune-forum/src/utils/fonts.ts b/apps/commune-forum/src/utils/fonts.ts
new file mode 100644
index 00000000..7b607222
--- /dev/null
+++ b/apps/commune-forum/src/utils/fonts.ts
@@ -0,0 +1,12 @@
+import { Cairo, Oxanium } from "next/font/google";
+
+export const cairo = Cairo({
+ subsets: ["latin"],
+ display: "swap",
+});
+
+export const oxanium = Oxanium({
+ subsets: ["latin"],
+ display: "swap",
+ weight: "300",
+});
diff --git a/apps/commune-forum/src/utils/index.tsx b/apps/commune-forum/src/utils/index.tsx
new file mode 100644
index 00000000..7039aaa5
--- /dev/null
+++ b/apps/commune-forum/src/utils/index.tsx
@@ -0,0 +1,243 @@
+import { match } from "rustie";
+
+import type {
+ CustomMetadataState,
+ DAOCardFields,
+ ProposalCardFields,
+ ProposalState,
+ ProposalStatus,
+} from "@commune-ts/types";
+import {
+ bigintDivision,
+ formatToken,
+ paramNameToDisplayName,
+} from "@commune-ts/utils";
+
+const paramsToMarkdown = (params: Record): string => {
+ const items = [];
+ for (const [key, value] of Object.entries(params)) {
+ const label = `**${paramNameToDisplayName(key)}**`;
+ const formattedValue =
+ typeof value === "string" || typeof value === "number"
+ ? `\`${value}\``
+ : "`???`";
+
+ items.push(`${label}: ${formattedValue}`);
+ }
+ return `${items.join(" | ")}\n`;
+};
+
+function handleCustomProposalData(
+ proposalId: number,
+ dataState: CustomMetadataState | null,
+ netuid: number | "GLOBAL",
+): ProposalCardFields {
+ if (dataState == null) {
+ return {
+ title: null,
+ body: null,
+ netuid,
+ };
+ }
+ return match(dataState)({
+ Err(): ProposalCardFields {
+ return {
+ title: `ID: ${proposalId} | This proposal has no custom metadata`,
+ body: null,
+ netuid,
+ invalid: true,
+ };
+ },
+ Ok(data): ProposalCardFields {
+ return {
+ title: data.title ?? null,
+ body: data.body ?? null,
+ netuid,
+ };
+ },
+ });
+}
+
+function handleProposalParams(
+ proposalId: number,
+ params: Record,
+ netuid: number | "GLOBAL",
+): ProposalCardFields {
+ const title = `Parameters proposal #${proposalId} for ${netuid == "GLOBAL" ? "global network" : `subnet ${netuid}`
+ }`;
+ return {
+ title,
+ body: paramsToMarkdown(params),
+ netuid,
+ };
+}
+
+export const handleCustomProposal = (
+ proposal: ProposalState,
+): ProposalCardFields =>
+ match(proposal.data)({
+ globalCustom(): ProposalCardFields {
+ return handleCustomProposalData(
+ proposal.id,
+ proposal.customData ?? null,
+ "GLOBAL",
+ );
+ },
+ subnetCustom({ subnetId }): ProposalCardFields {
+ return handleCustomProposalData(
+ proposal.id,
+ proposal.customData ?? null,
+ subnetId,
+ );
+ },
+ globalParams(params): ProposalCardFields {
+ return handleProposalParams(proposal.id, params, "GLOBAL");
+ },
+ subnetParams({ subnetId, params }): ProposalCardFields {
+ return handleProposalParams(proposal.id, params, subnetId);
+ },
+ transferDaoTreasury(): ProposalCardFields {
+ return handleCustomProposalData(
+ proposal.id,
+ proposal.customData ?? null,
+ "GLOBAL",
+ );
+ },
+ });
+
+export function calcProposalFavorablePercent(
+ proposalStatus: ProposalStatus,
+): number | null {
+ function calcStakePercent(
+ stakeFor: bigint,
+ stakeAgainst: bigint,
+ ): number | null {
+ const totalStake = stakeFor + stakeAgainst;
+ if (totalStake === 0n) {
+ return null;
+ }
+ const ratio = bigintDivision(stakeFor, totalStake);
+ const percentage = ratio * 100;
+ return percentage;
+ }
+ return match(proposalStatus)({
+ open: ({ stakeFor, stakeAgainst }) =>
+ calcStakePercent(stakeFor, stakeAgainst),
+ accepted: ({ stakeFor, stakeAgainst }) =>
+ calcStakePercent(stakeFor, stakeAgainst),
+ refused: ({ stakeFor, stakeAgainst }) =>
+ calcStakePercent(stakeFor, stakeAgainst),
+ expired: () => null,
+ });
+}
+
+export function handleProposalVotesInFavor(proposalStatus: ProposalStatus) {
+ return match(proposalStatus)({
+ open: ({ stakeFor }) => formatToken(Number(stakeFor)),
+ accepted: ({ stakeFor }) => formatToken(Number(stakeFor)),
+ refused: ({ stakeFor }) => formatToken(Number(stakeFor)),
+ expired: () => "—",
+ });
+}
+
+export function handleProposalVotesAgainst(proposalStatus: ProposalStatus) {
+ return match(proposalStatus)({
+ open: ({ stakeAgainst }) => formatToken(Number(stakeAgainst)),
+ accepted: ({ stakeAgainst }) => formatToken(Number(stakeAgainst)),
+ refused: ({ stakeAgainst }) => formatToken(Number(stakeAgainst)),
+ expired: () => "—",
+ });
+}
+
+export function handleProposalStakeVoted(
+ proposalStatus: ProposalStatus,
+): string {
+ // TODO: extend rustie `if_let` to provide other variants on else arm
+ // const txt = if_let(proposalStatus)("expired")(() => "—")(({ stakeFor }) => formatToken(Number(stakeFor)));
+
+ return match(proposalStatus)({
+ open: ({ stakeFor, stakeAgainst }) =>
+ formatToken(Number(stakeFor + stakeAgainst)),
+ accepted: ({ stakeFor, stakeAgainst }) =>
+ formatToken(Number(stakeFor + stakeAgainst)),
+ refused: ({ stakeFor, stakeAgainst }) =>
+ formatToken(Number(stakeFor + stakeAgainst)),
+ expired: () => "—",
+ });
+}
+
+export function handleProposalQuorumPercent(
+ proposalStatus: ProposalStatus,
+ totalStake: bigint,
+): JSX.Element {
+ function quorumPercent(stakeFor: bigint, stakeAgainst: bigint): JSX.Element {
+ const percentage =
+ bigintDivision(stakeFor + stakeAgainst, totalStake) * 100;
+ const percentDisplay = `${Number.isNaN(percentage) ? "—" : percentage.toFixed(1)}%`;
+ return {`(${percentDisplay})`};
+ }
+ return match(proposalStatus)({
+ open: ({ stakeFor, stakeAgainst }) => quorumPercent(stakeFor, stakeAgainst),
+ accepted: ({ stakeFor, stakeAgainst }) =>
+ quorumPercent(stakeFor, stakeAgainst),
+ refused: ({ stakeFor, stakeAgainst }) =>
+ quorumPercent(stakeFor, stakeAgainst),
+ expired: () => {
+ return {` (Matured)`};
+ },
+ });
+}
+
+// == DAO Applications ==
+
+export function handleDaoApplications(
+ daoId: number | null,
+ dataState: CustomMetadataState | null,
+): DAOCardFields {
+ if (dataState == null) {
+ return {
+ title: null,
+ body: null,
+ };
+ }
+ return match(dataState)({
+ Err(): DAOCardFields {
+ return {
+ title: `ID: ${daoId} | This DAO has no custom metadata`,
+ body: null,
+ };
+ },
+ Ok(data): DAOCardFields {
+ return {
+ title: data.title ?? null,
+ body: data.body ?? null,
+ };
+ },
+ });
+}
+
+export function handleCustomDaos(
+ daoId: number | null,
+ dataState: CustomMetadataState | null,
+): DAOCardFields {
+ if (dataState == null) {
+ return {
+ title: null,
+ body: null,
+ };
+ }
+ return match(dataState)({
+ Err(): DAOCardFields {
+ return {
+ title: `ID: ${daoId} | This DAO has no custom metadata`,
+ body: null,
+ };
+ },
+ Ok(data): DAOCardFields {
+ return {
+ title: data.title ?? null,
+ body: data.body ?? null,
+ };
+ },
+ });
+}
diff --git a/apps/commune-forum/tailwind.config.ts b/apps/commune-forum/tailwind.config.ts
new file mode 100644
index 00000000..366f24a9
--- /dev/null
+++ b/apps/commune-forum/tailwind.config.ts
@@ -0,0 +1,14 @@
+import type { Config } from "tailwindcss";
+
+import baseConfig from "@commune-ts/tailwind-config/web";
+
+export default {
+ // We need to append the path to the UI package to the content array so that
+ // those classes are included correctly.
+ content: [
+ ...baseConfig.content,
+ "../../packages/ui/src/components/*.{ts,tsx}",
+ ],
+ presets: [baseConfig],
+ theme: {},
+} satisfies Config;
diff --git a/apps/commune-forum/tsconfig.json b/apps/commune-forum/tsconfig.json
new file mode 100644
index 00000000..74901e1a
--- /dev/null
+++ b/apps/commune-forum/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "@commune-ts/tsconfig/base.json",
+ "compilerOptions": {
+ "lib": ["es2022", "dom", "dom.iterable"],
+ "jsx": "preserve",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "plugins": [{ "name": "next" }],
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
+ "module": "esnext"
+ },
+ "include": [".", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/commune-forum/turbo.json b/apps/commune-forum/turbo.json
new file mode 100644
index 00000000..e7dafe48
--- /dev/null
+++ b/apps/commune-forum/turbo.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://turborepo.org/schema.json",
+ "extends": [
+ "//"
+ ],
+ "tasks": {
+ "build": {
+ "dependsOn": [
+ "^build"
+ ],
+ "outputs": [
+ ".next/**",
+ "!.next/cache/**",
+ "next-env.d.ts"
+ ]
+ },
+ "dev": {
+ "persistent": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/commune-worker/src/workers/weight-aggregator.ts b/apps/commune-worker/src/workers/weight-aggregator.ts
index bc358221..b51ac0fb 100644
--- a/apps/commune-worker/src/workers/weight-aggregator.ts
+++ b/apps/commune-worker/src/workers/weight-aggregator.ts
@@ -372,7 +372,6 @@ async function getUserSubnetWeightMap(): Promise<
userSubnetDataSchema,
eq(subnetDataSchema.netuid, userSubnetDataSchema.netuid),
);
-
const weightMap = new Map>();
result.forEach((entry) => {
if (!weightMap.has(entry.userKey)) {
diff --git a/package.json b/package.json
index d824f9f5..05278679 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "commune-ts",
"private": true,
"scripts": {
+ "api:build": "turbo -F @commune-ts/api build",
"build": "turbo run build",
"clean": "git clean -xdf node_modules",
"clean:all": "find . -type d \\( -name 'node_modules' -o -name '.next' -o -name '.turbo' -o -name 'dist' -o -name '.cache' \\) -prune -exec rm -rf '{}' +",
@@ -12,6 +13,7 @@
"dev:api": "turbo watch dev -F @commune-ts/api",
"dev:cache": "turbo dev -F commune-cache",
"dev:db": "turbo watch dev -F @commune-ts/db",
+ "dev:forum": "turbo watch dev -F commune-forum",
"dev:governance": "turbo watch dev -F commune-governance",
"dev:page": "turbo watch dev -F commune-page",
"dev:roadmap": "turbo watch dev -F communex-roadmap",
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index ac233b8c..c7ed40e5 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,5 +1,6 @@
import { authRouter } from "./router/auth";
import { daoRouter } from "./router/dao";
+import { forumRouter } from "./router/forum";
import { moduleRouter } from "./router/module";
import { proposalCommentRouter } from "./router/proposal-comment";
import { subnetRouter } from "./router/subnet";
@@ -11,7 +12,7 @@ export const appRouter = createTRPCRouter({
module: moduleRouter,
subnet: subnetRouter,
proposalComment: proposalCommentRouter,
+ forum: forumRouter,
});
-// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/api/src/router/forum.ts b/packages/api/src/router/forum.ts
new file mode 100644
index 00000000..e281b736
--- /dev/null
+++ b/packages/api/src/router/forum.ts
@@ -0,0 +1,441 @@
+import type { TRPCRouterRecord } from "@trpc/server";
+import { z } from "zod";
+
+import { and, asc, desc, eq, sql } from "@commune-ts/db";
+import {
+ forumCategoriesSchema,
+ forumCommentSchema,
+ forumPostDigestView,
+ forumPostSchema,
+ forumPostViewCountSchema,
+ forumPostVotesSchema,
+} from "@commune-ts/db/schema";
+
+import { authenticatedProcedure, publicProcedure } from "../trpc";
+
+let cachedCategories:
+ | { id: number; name: string; description?: string }[]
+ | null = null;
+let cacheTimestamp = 0;
+const CACHE_DURATION = 1000 * 60 * 60; // 1 hour in milliseconds
+
+export const forumRouter = {
+ // GET
+ all: publicProcedure
+ .input(
+ z.object({
+ sortOrder: z.enum(["ASC", "DESC"]).default("DESC"),
+ sortBy: z.enum(["createdAt", "upvotes"]).default("createdAt"),
+ page: z.number().min(1).default(1),
+ pageSize: z.number().min(1).max(100).default(15),
+ categoryId: z.number().nullable().default(null),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const offset = (input.page - 1) * input.pageSize;
+
+ const whereConditions = [];
+
+ if (input.categoryId !== null) {
+ whereConditions.push(
+ eq(forumPostDigestView.categoryId, input.categoryId),
+ );
+ }
+
+ const sortByColumn =
+ input.sortBy === "createdAt"
+ ? forumPostDigestView.createdAt
+ : forumPostDigestView.upvotes;
+
+ const totalPostsResult = await ctx.db
+ .select({
+ count: sql`count(*)`,
+ })
+ .from(forumPostDigestView)
+ .where(
+ whereConditions.length > 0 ? and(...whereConditions) : undefined,
+ );
+
+ const totalPosts = totalPostsResult[0]?.count ?? 0;
+
+ const posts = await ctx.db
+ .select({
+ id: forumPostDigestView.id,
+ title: forumPostDigestView.title,
+ userKey: forumPostDigestView.userKey,
+ isAnonymous: forumPostDigestView.isAnonymous,
+ categoryName: forumPostDigestView.categoryName,
+ href: forumPostDigestView.href,
+ createdAt: forumPostDigestView.createdAt,
+ downvotes: forumPostDigestView.downvotes,
+ upvotes: forumPostDigestView.upvotes,
+ categoryId: forumPostDigestView.categoryId,
+ isPinned: forumPostDigestView.isPinned,
+ commentCount: forumPostDigestView.commentCount,
+ viewCount: forumPostViewCountSchema.viewCount,
+ })
+ .from(forumPostDigestView)
+ .leftJoin(
+ forumPostViewCountSchema,
+ eq(forumPostDigestView.id, forumPostViewCountSchema.postId),
+ )
+ .where(whereConditions.length > 0 ? and(...whereConditions) : undefined)
+ .orderBy(
+ desc(forumPostDigestView.isPinned),
+ input.sortOrder === "DESC" ? desc(sortByColumn) : asc(sortByColumn),
+ )
+ .limit(input.pageSize)
+ .offset(offset);
+
+ const totalPages = Math.ceil(totalPosts / input.pageSize);
+
+ return {
+ posts,
+ totalPosts,
+ totalPages,
+ };
+ }),
+ byId: publicProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.db
+ .select({
+ id: forumPostDigestView.id,
+ title: forumPostDigestView.title,
+ userKey: forumPostDigestView.userKey,
+ isAnonymous: forumPostDigestView.isAnonymous,
+ categoryName: forumPostDigestView.categoryName,
+ href: forumPostDigestView.href,
+ content: forumPostDigestView.content,
+ createdAt: forumPostDigestView.createdAt,
+ downvotes: forumPostDigestView.downvotes,
+ upvotes: forumPostDigestView.upvotes,
+ categoryId: forumPostDigestView.categoryId,
+ isPinned: forumPostDigestView.isPinned,
+ commentCount: forumPostDigestView.commentCount,
+ viewCount: forumPostViewCountSchema.viewCount,
+ })
+ .from(forumPostDigestView)
+ .leftJoin(
+ forumPostViewCountSchema,
+ eq(forumPostDigestView.id, forumPostViewCountSchema.postId),
+ )
+ .where(eq(forumPostDigestView.id, input.id))
+ .limit(1);
+ return result[0] ?? null;
+ }),
+
+ getCommentsByPost: publicProcedure
+ .input(z.object({ postId: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const comments = await ctx.db
+ .select()
+ .from(forumCommentSchema)
+ .where(
+ and(
+ eq(forumCommentSchema.postId, input.postId),
+ sql`${forumCommentSchema.deletedAt} IS NULL`,
+ ),
+ )
+ .orderBy(asc(forumCommentSchema.createdAt));
+ return comments;
+ }),
+
+ getCategories: publicProcedure.query(async ({ ctx }) => {
+ try {
+ const now = Date.now();
+ if (cachedCategories && now - cacheTimestamp < CACHE_DURATION) {
+ return cachedCategories;
+ }
+
+ const categories = await ctx.db
+ .select({
+ id: forumCategoriesSchema.id,
+ name: forumCategoriesSchema.name,
+ })
+ .from(forumCategoriesSchema)
+ .where(sql`${forumCategoriesSchema.deletedAt} IS NULL`)
+ .orderBy(forumCategoriesSchema.name);
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ cachedCategories = categories;
+ cacheTimestamp = now;
+
+ return categories;
+ } catch (error) {
+ console.error("Error fetching categories:", error);
+ throw new Error("Something went wrong while fetching categories.");
+ }
+ }),
+
+ // getViewsByPost: publicProcedure.input(z.object({postId: z.string()})).query(async ({ ctx, input }) => {
+ // try {
+ // const postViewCount = await ctx.db.query.forumPostViewCountSchema.findFirst({
+ // columns: {
+ // postId: true,
+ // viewCount: true,
+ // },
+ // where: eq(forumPostViewCountSchema.postId, input.postId),
+ // })
+
+ // return postViewCount;
+
+ // } catch (error) {
+ // console.error("Error fetching views by id:", error);
+ // throw new Error("Something went wrong while fetching views by id.");
+ // }
+ // }),
+
+ getPostVotesByUserId: publicProcedure
+ .input(
+ z.object({
+ userKey: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ try {
+ const votes = await ctx.db.query.forumPostVotesSchema.findMany({
+ where: input.userKey
+ ? eq(forumPostVotesSchema.userKey, input.userKey)
+ : undefined,
+ });
+
+ return votes;
+ } catch (error) {
+ console.error("Error fetching views by id:", error);
+ throw new Error("Something went wrong while fetching views by id.");
+ }
+ }),
+
+ getPostVotesByPostId: publicProcedure
+ .input(
+ z.object({
+ postId: z.string(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ try {
+ const votes = await ctx.db.query.forumPostVotesSchema.findMany({
+ where: input.postId
+ ? eq(forumPostVotesSchema.postId, input.postId)
+ : undefined,
+ });
+ return votes;
+ } catch (error) {
+ console.error("Error fetching votes by post id:", error);
+ throw new Error(
+ "Something went wrong while fetching votes by post id.",
+ );
+ }
+ }),
+
+ // POST
+ createPost: authenticatedProcedure
+ .input(
+ z.object({
+ userKey: z.string(),
+ isAnonymous: z.boolean().default(false),
+ title: z.string().min(1),
+ content: z.string().optional(),
+ href: z.string().url().optional(),
+ categoryId: z.number().int(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ // Ensure either content or href is provided, but not both
+ if (!(input.content || input.href) || (input.content && input.href)) {
+ throw new Error(
+ "Either content or href must be provided, but not both.",
+ );
+ }
+
+ // Validate that the categoryId exists and is not deleted
+ const categoryExists = await ctx.db
+ .select({ id: forumCategoriesSchema.id })
+ .from(forumCategoriesSchema)
+ .where(
+ and(
+ eq(forumCategoriesSchema.id, input.categoryId),
+ sql`${forumCategoriesSchema.deletedAt} IS NULL`,
+ ),
+ )
+ .limit(1);
+
+ if (categoryExists.length === 0) {
+ throw new Error("The provided categoryId does not exist.");
+ }
+
+ const newPost = await ctx.db
+ .insert(forumPostSchema)
+ .values({
+ userKey: input.userKey,
+ isAnonymous: input.isAnonymous,
+ title: input.title,
+ content: input.content,
+ href: input.href,
+ categoryId: input.categoryId,
+ })
+ .returning();
+
+ return newPost[0];
+ } catch (error) {
+ console.error("Error creating post:", error);
+ throw new Error("Something went wrong while creating the post.");
+ }
+ }),
+
+ createComment: authenticatedProcedure
+ .input(
+ z.object({
+ userKey: z.string(),
+ postId: z.string().uuid(),
+ content: z.string().min(1),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const newComment = await ctx.db
+ .insert(forumCommentSchema)
+ .values({
+ postId: input.postId,
+ userKey: input.userKey,
+ content: input.content,
+ })
+ .returning();
+ return newComment[0];
+ }),
+
+ votePost: authenticatedProcedure
+ .input(
+ z.object({
+ userKey: z.string(),
+ postId: z.string().uuid(),
+ voteType: z.enum(["UPVOTE", "DOWNVOTE"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ try {
+ const existingVote = await ctx.db.query.forumPostVotesSchema.findFirst({
+ where: and(
+ eq(forumPostVotesSchema.postId, input.postId),
+ eq(forumPostVotesSchema.userKey, input.userKey),
+ ),
+ });
+
+ if (existingVote?.voteType === input.voteType) {
+ await ctx.db
+ .delete(forumPostVotesSchema)
+ .where(eq(forumPostVotesSchema.id, existingVote.id));
+ return { success: true };
+ }
+
+ if (existingVote) {
+ await ctx.db
+ .update(forumPostVotesSchema)
+ .set({ voteType: input.voteType })
+ .where(eq(forumPostVotesSchema.id, existingVote.id));
+ } else {
+ await ctx.db.insert(forumPostVotesSchema).values({
+ postId: input.postId,
+ userKey: input.userKey,
+ voteType: input.voteType,
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error processing vote:", error);
+ throw new Error("Something went wrong while processing the vote.");
+ }
+ }),
+
+ // TODO: IMPLEMENT UPDATE COMMENTS FUNCTIONALITY
+ // updateComment: authenticatedProcedure
+ // .input(
+ // z.object({
+ // userKey: z.string(),
+ // commentId: z.string().uuid(),
+ // content: z.string().min(1),
+ // })
+ // )
+ // .mutation(async ({ ctx, input }) => {
+ // const result = await ctx.db
+ // .update(forumCommentSchema)
+ // .set({ content: input.content })
+ // .where(
+ // and(
+ // eq(forumCommentSchema.id, input.commentId),
+ // eq(forumCommentSchema.userKey, input.userKey),
+ // sql`${forumCommentSchema.deletedAt} IS NULL`
+ // )
+ // );
+
+ // return { success: result.count > 0 };
+ // }),
+
+ // TODO: IMPLEMENT DELETE COMMENTS FUNCTIONALITY
+ // deleteComment: authenticatedProcedure
+ // .input(
+ // z.object({
+ // userKey: z.string(),
+ // commentId: z.string().uuid(),
+ // })
+ // )
+ // .mutation(async ({ ctx, input }) => {
+ // const result = await ctx.db
+ // .update(forumCommentSchema)
+ // .set({ deletedAt: new Date() })
+ // .where(
+ // and(
+ // eq(forumCommentSchema.id, input.commentId),
+ // eq(forumCommentSchema.userKey, input.userKey),
+ // sql`${forumCommentSchema.deletedAt} IS NULL`
+ // )
+ // );
+ // return { success: result.count > 0 };
+ // }),
+
+ // TODO: IMPLEMENT PIN POST FUNCTIONALITY
+ // pinPost: authenticatedProcedure // This should be restricted to admins
+ // .input(z.object({ postId: z.string().uuid(), isPinned: z.boolean() }))
+ // .mutation(async ({ ctx, input }) => {
+ // await ctx.db
+ // .update(forumPostSchema)
+ // .set({ isPinned: input.isPinned })
+ // .where(eq(forumPostSchema.id, input.postId));
+ // return { success: true };
+ // }),
+
+ incrementViewCount: publicProcedure
+ .input(z.object({ postId: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ // Ensure the post exists and is not deleted
+ const postExists = await ctx.db
+ .select({ id: forumPostSchema.id })
+ .from(forumPostSchema)
+ .where(
+ and(
+ eq(forumPostSchema.id, input.postId),
+ sql`${forumPostSchema.deletedAt} IS NULL`,
+ ),
+ )
+ .limit(1);
+
+ if (postExists.length === 0) {
+ throw new Error("The post does not exist.");
+ }
+
+ await ctx.db
+ .insert(forumPostViewCountSchema)
+ .values({ postId: input.postId, viewCount: 1 })
+ .onConflictDoUpdate({
+ target: forumPostViewCountSchema.postId,
+ set: {
+ viewCount: sql`${forumPostViewCountSchema.viewCount} + 1`,
+ },
+ });
+
+ return { success: true };
+ }),
+} satisfies TRPCRouterRecord;
diff --git a/packages/db/drizzle/0000_naive_anthem.sql b/packages/db/drizzle/0000_naive_anthem.sql
new file mode 100644
index 00000000..dac2379c
--- /dev/null
+++ b/packages/db/drizzle/0000_naive_anthem.sql
@@ -0,0 +1,48 @@
+-- Custom SQL migration file, put you code below! --
+
+-- Drop the existing view
+DROP VIEW IF EXISTS forum_post_digest;
+
+-- Create the updated view
+CREATE VIEW forum_post_digest AS
+SELECT
+ fp.id,
+ fp.user_key,
+ fp.title,
+ fp.content,
+ fp.created_at,
+ fp.updated_at,
+ fp.is_anonymous,
+ fp.is_pinned,
+ fp.href,
+ fp.category_id,
+ fc.name,
+ COALESCE(
+ (SELECT COUNT(*) FROM forum_post_votes fpv
+ WHERE fpv.post_id = fp.id
+ AND fpv.vote_type = 'UPVOTE'),
+ 0
+ ) AS "upvotes",
+ COALESCE(
+ (SELECT COUNT(*) FROM forum_post_votes fpv
+ WHERE fpv.post_id = fp.id
+ AND fpv.vote_type = 'DOWNVOTE'),
+ 0
+ ) AS "downvotes",
+ COALESCE(
+ (SELECT COUNT(*) FROM forum_comment fcmt
+ WHERE fcmt.post_id = fp.id
+ AND fcmt.deleted_at IS NULL),
+ 0
+ ) AS "commentCount"
+FROM forum_post fp
+LEFT JOIN forum_categories fc ON fp.category_id = fc.id
+WHERE fp.deleted_at IS NULL;
+
+INSERT INTO forum_categories (id, name, description) VALUES
+(1, 'GENERAL', 'Broad discussions related to the community''s interests.'),
+(2, 'NEWS', 'Official updates and news from administrators.'),
+(3, 'TECH', 'In-depth technical topics, including web-related discussions.'),
+(4, 'META', 'Discussions about the forum itself.'),
+(5, 'OFF-TOPIC', 'General conversations not related to specific topics');
+(6, 'OTHERS', 'Posts without a specific category');
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json
new file mode 100644
index 00000000..ebb548b3
--- /dev/null
+++ b/packages/db/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,15 @@
+{
+ "id": "ac4eb4a1-6134-426c-bc25-b2fb95f71e4f",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {},
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
new file mode 100644
index 00000000..8bd7a567
--- /dev/null
+++ b/packages/db/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1728438824862,
+ "tag": "0000_naive_anthem",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index cd2bf97f..707062a0 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -2,6 +2,7 @@ import { asc, eq, sql } from "drizzle-orm";
import {
bigint,
boolean,
+ check,
index,
integer,
numeric,
@@ -205,6 +206,7 @@ export const moduleReport = createTable("module_report", {
export const governanceModelEnum = pgEnum("governance_model", [
"PROPOSAL",
"DAO",
+ "FORUM",
]);
export const proposalCommentSchema = createTable(
@@ -220,7 +222,9 @@ export const proposalCommentSchema = createTable(
deletedAt: timestamp("deleted_at").default(sql`null`),
},
(t) => ({
- proposalIdIndex: index("proposal_id_index").on(t.proposalId),
+ proposalIdIndex: index("proposal_comment_proposal_id_index").on(
+ t.proposalId,
+ ),
}),
);
@@ -245,8 +249,13 @@ export const commentInteractionSchema = createTable(
},
(t) => ({
unq: unique().on(t.commentId, t.userKey),
- commentIdIndex: index("comment_id_index").on(t.commentId),
- commentVoteIndex: index("comment_vote_index").on(t.commentId, t.voteType),
+ commentIdIndex: index("comment_interaction_comment_id_index").on(
+ t.commentId,
+ ),
+ commentVoteIndex: index("comment_interaction_comment_vote_index").on(
+ t.commentId,
+ t.voteType,
+ ),
}),
);
@@ -430,3 +439,231 @@ export const computedSubnetWeights = createTable("computed_subnet_weights", {
createdAt: timestamp("created_at").defaultNow().notNull(),
});
+
+// ----- FORUM SCHEMAS -------
+
+export const forumVoteType = pgEnum("forum_vote_type_enum", [
+ "UPVOTE",
+ "DOWNVOTE",
+]);
+
+export const forumCategoriesSchema = createTable("forum_categories", {
+ id: serial("id").primaryKey(),
+ name: text("name").notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ deletedAt: timestamp("deleted_at").default(sql`null`),
+});
+
+export const forumPostSchema = createTable(
+ "forum_post",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ categoryId: integer("category_id")
+ .references(() => forumCategoriesSchema.id)
+ .notNull(),
+ userKey: ss58Address("user_key").notNull(),
+ isAnonymous: boolean("is_anonymous").default(false).notNull(),
+ isPinned: boolean("is_pinned").default(false).notNull(),
+ title: text("title").notNull(),
+ content: text("content"),
+ href: text("href"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ deletedAt: timestamp("deleted_at").default(sql`null`),
+ },
+ (t) => ({
+ contentOrHrefCheck: check(
+ "content_or_href_check",
+ sql`(content IS NOT NULL AND href IS NULL) OR (content IS NULL AND href IS NOT NULL)`,
+ ),
+ categoryIdIndex: index("forum_post_category_id_index").on(t.categoryId),
+ userKeyIndex: index("forum_post_user_key_index").on(t.userKey),
+ }),
+);
+
+export const forumPostViewCountSchema = createTable("forum_post_view_count", {
+ postId: uuid("post_id")
+ .references(() => forumPostSchema.id)
+ .notNull()
+ .primaryKey(),
+ viewCount: integer("view_count").default(0).notNull(),
+});
+
+export const forumCommentSchema = createTable(
+ "forum_comment",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ postId: uuid("post_id")
+ .references(() => forumPostSchema.id)
+ .notNull(),
+ userKey: ss58Address("user_key").notNull(),
+ content: text("content").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ deletedAt: timestamp("deleted_at").default(sql`null`),
+ },
+ (t) => ({
+ postIdIndex: index("forum_comment_post_id_index").on(t.postId),
+ }),
+);
+
+export const forumPostVotesSchema = createTable(
+ "forum_post_votes",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ postId: uuid("post_id")
+ .references(() => forumPostSchema.id)
+ .notNull(),
+ userKey: ss58Address("user_key").notNull(),
+ voteType: forumVoteType("vote_type").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ },
+ (t) => ({
+ unq: unique().on(t.postId, t.userKey),
+ postIdIndex: index("forum_post_votes_post_id_index").on(t.postId),
+ postVoteIndex: index("forum_post_votes_post_vote_index").on(
+ t.postId,
+ t.voteType,
+ ),
+ userKeyIndex: index("forum_post_votes_user_key_index").on(t.userKey),
+ }),
+);
+
+export const forumCommentVotesSchema = createTable(
+ "forum_comment_votes",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ commentId: uuid("comment_id")
+ .references(() => forumCommentSchema.id)
+ .notNull(),
+ userKey: ss58Address("user_key").notNull(),
+ voteType: forumVoteType("vote_type").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .notNull()
+ .$onUpdate(() => new Date()),
+ },
+ (t) => ({
+ uniqueVote: unique().on(t.commentId, t.userKey),
+ commentIdIndex: index("forum_comment_votes_comment_id_index").on(
+ t.commentId,
+ ),
+ voteTypeIndex: index("forum_comment_votes_vote_type_index").on(t.voteType),
+ userKeyIndex: index("forum_comment_votes_user_key_index").on(t.userKey),
+ }),
+);
+
+export const reportedTypeEnum = pgEnum("reportedType", ["POST", "COMMENT"]);
+
+export const forumReportSchema = createTable("forum_report", {
+ id: serial("id").primaryKey(),
+ userKey: ss58Address("user_key").notNull(),
+ reportedId: uuid("reported_id").notNull(),
+ reportedType: reportedTypeEnum("reported_type").notNull(),
+ content: text("content").notNull(),
+ reason: ReportReasonEnum("reason").notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+});
+
+export const forumCommentDigestView = pgView("forum_comment_digest").as((qb) =>
+ qb
+ .select({
+ id: forumCommentSchema.id,
+ postId: forumCommentSchema.postId,
+ userKey: forumCommentSchema.userKey,
+ content: forumCommentSchema.content,
+ createdAt: forumCommentSchema.createdAt,
+ updatedAt: forumCommentSchema.updatedAt,
+ upvotes: sql`
+ COALESCE(
+ (SELECT COUNT(*) FROM ${forumCommentVotesSchema}
+ WHERE ${forumCommentVotesSchema.commentId} = ${forumCommentSchema.id}
+ AND ${forumCommentVotesSchema.voteType} = 'UPVOTE'),
+ 0
+ )
+ `
+ .mapWith(Number)
+ .as("upvotes"),
+ downvotes: sql`
+ COALESCE(
+ (SELECT COUNT(*) FROM ${forumCommentVotesSchema}
+ WHERE ${forumCommentVotesSchema.commentId} = ${forumCommentSchema.id}
+ AND ${forumCommentVotesSchema.voteType} = 'DOWNVOTE'),
+ 0
+ )
+ `
+ .mapWith(Number)
+ .as("downvotes"),
+ })
+ .from(forumCommentSchema)
+ .where(sql`${forumCommentSchema.deletedAt} IS NULL`),
+);
+
+export const forumPostDigestView = pgView("forum_post_digest").as((qb) =>
+ qb
+ .select({
+ id: forumPostSchema.id,
+ userKey: forumPostSchema.userKey,
+ title: forumPostSchema.title,
+ content: forumPostSchema.content,
+ createdAt: forumPostSchema.createdAt,
+ updatedAt: forumPostSchema.updatedAt,
+ isAnonymous: forumPostSchema.isAnonymous,
+ isPinned: forumPostSchema.isPinned,
+ href: forumPostSchema.href,
+ categoryId: forumPostSchema.categoryId,
+ categoryName: forumCategoriesSchema.name,
+ upvotes: sql`
+ COALESCE(
+ (SELECT COUNT(*) FROM ${forumPostVotesSchema}
+ WHERE ${forumPostVotesSchema.postId} = ${forumPostSchema.id}
+ AND ${forumPostVotesSchema.voteType} = 'UPVOTE'),
+ 0
+ )
+ `
+ .mapWith(Number)
+ .as("upvotes"),
+ downvotes: sql`
+ COALESCE(
+ (SELECT COUNT(*) FROM ${forumPostVotesSchema}
+ WHERE ${forumPostVotesSchema.postId} = ${forumPostSchema.id}
+ AND ${forumPostVotesSchema.voteType} = 'DOWNVOTE'),
+ 0
+ )
+ `
+ .mapWith(Number)
+ .as("downvotes"),
+ commentCount: sql`
+ COALESCE(
+ (SELECT COUNT(*) FROM ${forumCommentSchema}
+ WHERE ${forumCommentSchema.postId} = ${forumPostSchema.id}
+ AND ${forumCommentSchema.deletedAt} IS NULL),
+ 0
+ )
+ `
+ .mapWith(Number)
+ .as("commentCount"),
+ })
+ .from(forumPostSchema)
+ .leftJoin(
+ forumCategoriesSchema,
+ eq(forumPostSchema.categoryId, forumCategoriesSchema.id),
+ )
+ .where(sql`${forumPostSchema.deletedAt} IS NULL`),
+);
diff --git a/packages/subspace/queries/index.ts b/packages/subspace/queries/index.ts
index c717ad4b..abd0213a 100644
--- a/packages/subspace/queries/index.ts
+++ b/packages/subspace/queries/index.ts
@@ -21,12 +21,12 @@ import type {
} from "@commune-ts/utils";
import {
checkSS58,
+ STAKE_OUT_DATA_SCHEMA,
GOVERNANCE_CONFIG_SCHEMA,
isSS58,
+ STAKE_FROM_SCHEMA,
MODULE_BURN_CONFIG_SCHEMA,
NetworkSubnetConfigSchema,
- STAKE_FROM_SCHEMA,
- STAKE_OUT_DATA_SCHEMA,
SUBSPACE_MODULE_SCHEMA,
} from "@commune-ts/types";
import {
@@ -36,6 +36,7 @@ import {
standardizeUidToSS58address,
} from "@commune-ts/utils";
+
export { ApiPromise };
// == chain ==
diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx
index 7821504f..dfaa25dd 100644
--- a/packages/ui/src/components/checkbox.tsx
+++ b/packages/ui/src/components/checkbox.tsx
@@ -6,10 +6,15 @@ import { CheckIcon } from "@radix-ui/react-icons";
import { cn } from ".";
+interface CheckboxProps
+ extends React.ComponentPropsWithoutRef {
+ iconColor?: string;
+}
+
const Checkbox = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+ CheckboxProps
+>(({ className, iconColor = "", ...props }, ref) => (
-
+
));
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index e37f8a5a..959e479b 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -135,6 +135,16 @@ export {
TableCaption,
} from "./table";
export { Separator } from "./separator";
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "./pagination";
+
export {
ChartContainer,
ChartTooltip,
diff --git a/packages/ui/src/components/pagination.tsx b/packages/ui/src/components/pagination.tsx
new file mode 100644
index 00000000..aff3d9c1
--- /dev/null
+++ b/packages/ui/src/components/pagination.tsx
@@ -0,0 +1,122 @@
+import * as React from "react";
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ DotsHorizontalIcon,
+} from "@radix-ui/react-icons";
+
+import type { ButtonProps } from "./button";
+import { cn } from "../index";
+import { buttonVariants } from "./button";
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+);
+Pagination.displayName = "Pagination";
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationContent.displayName = "PaginationContent";
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationItem.displayName = "PaginationItem";
+
+type PaginationLinkProps = {
+ isActive?: boolean;
+} & Pick &
+ React.ComponentProps<"a">;
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+);
+PaginationLink.displayName = "PaginationLink";
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+);
+PaginationPrevious.displayName = "PaginationPrevious";
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+);
+PaginationNext.displayName = "PaginationNext";
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+);
+PaginationEllipsis.displayName = "PaginationEllipsis";
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+};
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index f5d81f57..f9b78f20 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -2,7 +2,6 @@ import { BN, stringToHex } from "@polkadot/util";
import { CID } from "multiformats/cid";
import { match } from "rustie";
import { AssertionError } from "tsafe";
-
import type {
AnyTuple,
Api,
@@ -360,10 +359,7 @@ export function getSubspaceStorageMappingKind(
prop: SubspaceStorageName,
): StorageTypes | null {
const vecProps: SubspaceStorageName[] = [
- "emission",
- "incentive",
- "dividends",
- "lastUpdate",
+ "emission", "incentive", "dividends", "lastUpdate",
];
const netuidMapProps: SubspaceStorageName[] = [
"metadata",
@@ -502,20 +498,20 @@ export class NetuidMapEntries implements ChainEntry {
return moduleIdToPropValue;
}
}
-
export class DoubleMapEntries implements ChainEntry {
- constructor(private readonly entries: [StorageKey, Codec][]) {}
+ constructor(private readonly entries: [StorageKey, Codec][]) { }
queryStorage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const moduleIdToPropValue: Record> = {};
- this.entries.forEach((entry) => {
+ this.entries.forEach(entry => {
const keyFrom = entry[0].args[0]?.toPrimitive() as string;
const keyTo = entry[0].args[1]?.toPrimitive() as string;
if (moduleIdToPropValue[keyFrom] === undefined) {
moduleIdToPropValue[keyFrom] = {};
}
moduleIdToPropValue[keyFrom][keyTo] = entry[1].toPrimitive() as string;
+
});
return moduleIdToPropValue;
}
@@ -677,3 +673,4 @@ export const signData = async (
address,
};
};
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cca0a10f..19762f84 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -209,6 +209,109 @@ importers:
specifier: 'catalog:'
version: 5.6.3
+ apps/commune-forum:
+ dependencies:
+ '@commune-ts/api':
+ specifier: workspace:*
+ version: link:../../packages/api
+ '@commune-ts/db':
+ specifier: workspace:*
+ version: link:../../packages/db
+ '@commune-ts/providers':
+ specifier: workspace:*
+ version: link:../../packages/providers
+ '@commune-ts/types':
+ specifier: workspace:*
+ version: link:../../packages/types
+ '@commune-ts/ui':
+ specifier: workspace:*
+ version: link:../../packages/ui
+ '@commune-ts/utils':
+ specifier: workspace:*
+ version: link:../../packages/utils
+ '@commune-ts/wallet':
+ specifier: workspace:*
+ version: link:../../packages/wallet
+ '@heroicons/react':
+ specifier: 'catalog:'
+ version: 2.1.5(react@18.3.1)
+ '@t3-oss/env-nextjs':
+ specifier: 'catalog:'
+ version: 0.10.1(typescript@5.6.3)(zod@3.23.8)
+ '@tanstack/react-query':
+ specifier: 'catalog:'
+ version: 5.59.13(react@18.3.1)
+ '@trpc/client':
+ specifier: 'catalog:'
+ version: 11.0.0-rc.571(@trpc/server@11.0.0-rc.571)
+ '@trpc/react-query':
+ specifier: 'catalog:'
+ version: 11.0.0-rc.571(@tanstack/react-query@5.59.13(react@18.3.1))(@trpc/client@11.0.0-rc.571(@trpc/server@11.0.0-rc.571))(@trpc/server@11.0.0-rc.571)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@trpc/server':
+ specifier: 'catalog:'
+ version: 11.0.0-rc.571
+ '@uiw/react-markdown-preview':
+ specifier: ^5.1.1
+ version: 5.1.3(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ luxon:
+ specifier: ^3.5.0
+ version: 3.5.0
+ next:
+ specifier: 'catalog:'
+ version: 14.2.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react:
+ specifier: catalog:react18
+ version: 18.3.1
+ react-dom:
+ specifier: catalog:react18
+ version: 18.3.1(react@18.3.1)
+ rustie:
+ specifier: 'catalog:'
+ version: 0.2.0
+ tsafe:
+ specifier: 'catalog:'
+ version: 1.7.5
+ zod:
+ specifier: 'catalog:'
+ version: 3.23.8
+ devDependencies:
+ '@commune-ts/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@commune-ts/tailwind-config':
+ specifier: workspace:*
+ version: link:../../tooling/tailwind
+ '@commune-ts/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@next/eslint-plugin-next':
+ specifier: 'catalog:'
+ version: 14.2.15
+ '@types/luxon':
+ specifier: ^3.4.2
+ version: 3.4.2
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.16.11
+ '@types/react':
+ specifier: catalog:react18
+ version: 18.3.11
+ '@types/react-dom':
+ specifier: catalog:react18
+ version: 18.3.1
+ jiti:
+ specifier: 'catalog:'
+ version: 1.21.6
+ postcss:
+ specifier: 'catalog:'
+ version: 8.4.47
+ tailwindcss:
+ specifier: 'catalog:'
+ version: 3.4.13(ts-node@10.9.2(@types/node@20.16.11)(typescript@5.6.3))
+ typescript:
+ specifier: 'catalog:'
+ version: 5.6.3
+
apps/commune-governance:
dependencies:
'@commune-ts/api':
@@ -1139,7 +1242,7 @@ importers:
version: 7.37.1(eslint@9.12.0(jiti@1.21.6))
eslint-plugin-react-hooks:
specifier: rc
- version: 5.1.0-rc-6cf85185-20241014(eslint@9.12.0(jiti@1.21.6))
+ version: 5.1.0-rc-1631855f-20241023(eslint@9.12.0(jiti@1.21.6))
eslint-plugin-turbo:
specifier: ^2.1.1
version: 2.1.3(eslint@9.12.0(jiti@1.21.6))
@@ -3036,6 +3139,9 @@ packages:
'@types/jsonwebtoken@9.0.7':
resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==}
+ '@types/luxon@3.4.2':
+ resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -4096,8 +4202,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
- eslint-plugin-react-hooks@5.1.0-rc-6cf85185-20241014:
- resolution: {integrity: sha512-BeEXOFhXkde3ltdeI6TaiNm/2Fq7wevwjf6c+nPG57QewYiVRD5W910pEnsJikneK1QWrX5OgZS1vpCpUxzfYw==}
+ eslint-plugin-react-hooks@5.1.0-rc-1631855f-20241023:
+ resolution: {integrity: sha512-ohDko9xwausG054sm2kvM0ZssnaZ0jRBL3tduCR9Bo3rott+zD8O9p6PC5B+LN/6f2RwKRNCdN2WcrkNul/ljg==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@@ -4965,6 +5071,10 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
+ luxon@3.5.0:
+ resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
+ engines: {node: '>=12'}
+
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -8379,11 +8489,11 @@ snapshots:
'@types/body-parser@1.19.5':
dependencies:
'@types/connect': 3.4.38
- '@types/node': 20.16.11
+ '@types/node': 22.7.5
'@types/connect@3.4.38':
dependencies:
- '@types/node': 20.16.11
+ '@types/node': 22.7.5
'@types/cors@2.8.17':
dependencies:
@@ -8425,7 +8535,7 @@ snapshots:
'@types/express-serve-static-core@4.19.6':
dependencies:
- '@types/node': 20.16.11
+ '@types/node': 22.7.5
'@types/qs': 6.9.16
'@types/range-parser': 1.2.7
'@types/send': 0.17.4
@@ -8467,6 +8577,8 @@ snapshots:
dependencies:
'@types/node': 22.7.5
+ '@types/luxon@3.4.2': {}
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -8507,12 +8619,12 @@ snapshots:
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
- '@types/node': 20.16.11
+ '@types/node': 22.7.5
'@types/serve-static@1.15.7':
dependencies:
'@types/http-errors': 2.0.4
- '@types/node': 20.16.11
+ '@types/node': 22.7.5
'@types/send': 0.17.4
'@types/stats.js@0.17.3': {}
@@ -9681,7 +9793,7 @@ snapshots:
safe-regex-test: 1.0.3
string.prototype.includes: 2.0.0
- eslint-plugin-react-hooks@5.1.0-rc-6cf85185-20241014(eslint@9.12.0(jiti@1.21.6)):
+ eslint-plugin-react-hooks@5.1.0-rc-1631855f-20241023(eslint@9.12.0(jiti@1.21.6)):
dependencies:
eslint: 9.12.0(jiti@1.21.6)
@@ -10760,6 +10872,8 @@ snapshots:
lru-cache@7.18.3: {}
+ luxon@3.5.0: {}
+
make-error@1.3.6: {}
markdown-extensions@2.0.0: {}