From d315170a3784899ae5e94865bd3b85accdf3666b Mon Sep 17 00:00:00 2001 From: Degreat Date: Thu, 25 Apr 2024 20:37:01 +0000 Subject: [PATCH] Communities (#68) * rearrange navigation * properly style * comm form * create community * load community * approve community * list members * post to community, load post * progress * looking good * validation * collate nocase --- client/.eslintrc.cjs | 83 ---------- client/app/components/anchor.tsx | 6 +- client/app/components/avatar.tsx | 9 +- client/app/components/button.tsx | 3 +- client/app/components/community-info.tsx | 109 +++++++++++++ client/app/components/community-mod.tsx | 60 +++++++ client/app/components/crowdsource-notice.tsx | 2 +- client/app/components/error-boundary.tsx | 2 +- client/app/components/file-menu.tsx | 5 +- client/app/components/lesson-form.tsx | 18 +-- client/app/components/navbar.tsx | 131 +++++++++++---- client/app/components/post-content.tsx | 20 ++- client/app/components/post-input.tsx | 28 ++-- client/app/components/post-item.tsx | 38 +++-- .../components/timetable-save-to-calendar.tsx | 2 +- client/app/lib/check-mod.ts | 18 +++ client/app/lib/create-post.ts | 1 + client/app/lib/create-tags-query.ts | 4 +- client/app/lib/ellipsize.ts | 8 + client/app/lib/get-moderators.ts | 10 ++ client/app/lib/username-regex.tsx | 4 + client/app/root.tsx | 44 ++++- client/app/routes/communities.tsx | 94 +++++++++++ .../app/routes/communities_.$slug.members.tsx | 51 ++++++ client/app/routes/communities_.$slug.mod.tsx | 56 +++++++ client/app/routes/communities_.$slug.tsx | 108 +++++++++++++ client/app/routes/communities_.created.tsx | 15 ++ client/app/routes/communities_.new.tsx | 153 ++++++++++++++++++ client/app/routes/create-account.tsx | 3 +- client/app/routes/discussions.tsx | 42 +++-- client/app/routes/discussions_.$id.tsx | 16 +- client/app/routes/discussions_.$id_.$.tsx | 6 +- client/app/routes/events.tsx | 15 +- client/app/routes/events_.$id.tsx | 4 +- client/app/routes/events_.add.tsx | 4 +- client/app/routes/games.tsx | 3 + client/app/routes/library.tsx | 12 +- client/app/routes/market.tsx | 3 + client/app/routes/notifications.tsx | 59 +++---- client/app/routes/notifications_.$id.tsx | 27 ++-- client/app/routes/p.$username.communities.tsx | 75 +++++++++ client/app/routes/p.$username.tsx | 26 +-- client/app/routes/timetable.tsx | 2 +- ...ble_.$year.$programme.$level.$sem.$day.tsx | 10 +- client/app/style.css | 4 + .../20240421170420_communities/migration.sql | 25 +++ .../migration.sql | 46 ++++++ .../migration.sql | 32 ++++ .../migration.sql | 28 ++++ client/prisma/schema.prisma | 66 ++++++-- 50 files changed, 1305 insertions(+), 285 deletions(-) delete mode 100644 client/.eslintrc.cjs create mode 100644 client/app/components/community-info.tsx create mode 100644 client/app/components/community-mod.tsx create mode 100644 client/app/lib/check-mod.ts create mode 100644 client/app/lib/ellipsize.ts create mode 100644 client/app/lib/get-moderators.ts create mode 100644 client/app/lib/username-regex.tsx create mode 100644 client/app/routes/communities.tsx create mode 100644 client/app/routes/communities_.$slug.members.tsx create mode 100644 client/app/routes/communities_.$slug.mod.tsx create mode 100644 client/app/routes/communities_.$slug.tsx create mode 100644 client/app/routes/communities_.created.tsx create mode 100644 client/app/routes/communities_.new.tsx create mode 100644 client/app/routes/games.tsx create mode 100644 client/app/routes/market.tsx create mode 100644 client/app/routes/p.$username.communities.tsx create mode 100644 client/prisma/migrations/20240421170420_communities/migration.sql create mode 100644 client/prisma/migrations/20240421173802_communities_post/migration.sql create mode 100644 client/prisma/migrations/20240423165936_communities_unique_index/migration.sql create mode 100644 client/prisma/migrations/20240423192541_community_post/migration.sql diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs deleted file mode 100644 index 8f2bbcd..0000000 --- a/client/.eslintrc.cjs +++ /dev/null @@ -1,83 +0,0 @@ -/** - * This is intended to be a basic starting point for linting in your app. - * It relies on recommended configs out of the box for simplicity, but you can - * and should modify this configuration to best suit your team's needs. - */ - -/** @type {import('eslint').Linter.Config} */ -module.exports = { - root: true, - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - ecmaFeatures: { - jsx: true, - }, - }, - env: { - browser: true, - commonjs: true, - es6: true, - }, - - // Base config - extends: ["eslint:recommended"], - - overrides: [ - // React - { - files: ["**/*.{js,jsx,ts,tsx}"], - plugins: ["react", "jsx-a11y"], - extends: [ - "plugin:react/recommended", - "plugin:react/jsx-runtime", - "plugin:react-hooks/recommended", - "plugin:jsx-a11y/recommended", - ], - settings: { - react: { - version: "detect", - }, - formComponents: ["Form"], - linkComponents: [ - { name: "Link", linkAttribute: "to" }, - { name: "NavLink", linkAttribute: "to" }, - ], - "import/resolver": { - typescript: {}, - }, - }, - }, - - // Typescript - { - files: ["**/*.{ts,tsx}"], - plugins: ["@typescript-eslint", "import"], - parser: "@typescript-eslint/parser", - settings: { - "import/internal-regex": "^~/", - "import/resolver": { - node: { - extensions: [".ts", ".tsx"], - }, - typescript: { - alwaysTryTypes: true, - }, - }, - }, - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - ], - }, - - // Node - { - files: [".eslintrc.js"], - env: { - node: true, - }, - }, - ], -}; diff --git a/client/app/components/anchor.tsx b/client/app/components/anchor.tsx index f437039..449022d 100644 --- a/client/app/components/anchor.tsx +++ b/client/app/components/anchor.tsx @@ -1,14 +1,16 @@ +import { Link } from "@remix-run/react"; +import { RemixLinkProps } from "@remix-run/react/dist/components"; import clsx from "clsx"; import React from "react"; -interface Props extends React.ComponentProps<"a"> { +interface Props extends RemixLinkProps { variant?: "primary" | "neutral"; } const Anchor = React.forwardRef( ({ className, variant = "primary", ...props }, ref) => { return ( - - +
+
); } diff --git a/client/app/components/button.tsx b/client/app/components/button.tsx index f6a1c1c..5f3f579 100644 --- a/client/app/components/button.tsx +++ b/client/app/components/button.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import React from "react"; interface Props extends React.ComponentProps<"button"> { - variant?: "primary" | "neutral"; + variant?: "primary" | "neutral" | "secondary"; } const Button = React.forwardRef( @@ -15,6 +15,7 @@ const Button = React.forwardRef( { "bg-zinc-200 px-2 py-1 dark:bg-neutral-800": variant === "neutral", "text-white": variant === "primary", + "!bg-amber-500 text-white": variant === "secondary", "opacity-60": props.disabled, }, className, diff --git a/client/app/components/community-info.tsx b/client/app/components/community-info.tsx new file mode 100644 index 0000000..52e5f23 --- /dev/null +++ b/client/app/components/community-info.tsx @@ -0,0 +1,109 @@ +import { useFetcher, useLoaderData, useParams } from "@remix-run/react"; +import dayjs from "dayjs"; +import { useGlobalCtx } from "~/lib/global-ctx"; +import { loader } from "~/routes/communities_.$slug"; +import { Anchor } from "./anchor"; +import { Avatar } from "./avatar"; +import { Button } from "./button"; + +function CommunityInfo() { + const { user } = useGlobalCtx(); + const { community, membership, members } = useLoaderData(); + const { slug } = useParams(); + const fetcher = useFetcher(); + + async function join() { + fetcher.submit("", { + action: `/communities/${slug}/members`, + method: "POST", + }); + } + + return ( + <> +

{community.name}

+
+ +{community.handle} +
+ + {/*
*/} + +

{community.description}

+ + {!membership && ( +
+
+ Join to interact +
+

+ To be able to start and participate in conversations in this + community, you need to join first. +

+ +
+ {!user ? ( + Login & join + ) : ( + + )} +
+
+ )} + + {user && membership && ( +
+
+ Member since {dayjs(membership.createdAt).format("DD MMM YYYY")} +
+ {/** [ ] Add "leave community" */} +
+ )} + + {/*
+
+ Upcoming event +
+

Lecture 7: Developing a pixel art editor

+

Sat, 17 Apr.

+
*/} + +
+
+
+ {community.members} members +
+ +
    + {members.map((member) => ( +
  • +
    + + +
    + {member.user.username}{" "} + {member.role === "moderator" && ( + + MOD + + )} +
    +
    +
  • + ))} +
+
+ + ); +} + +export { CommunityInfo }; diff --git a/client/app/components/community-mod.tsx b/client/app/components/community-mod.tsx new file mode 100644 index 0000000..2d66352 --- /dev/null +++ b/client/app/components/community-mod.tsx @@ -0,0 +1,60 @@ +import { Community } from "@prisma/client"; +import { useFetcher } from "@remix-run/react"; +import { Jsonify } from "type-fest"; +import { useGlobalCtx } from "~/lib/global-ctx"; +import { Button } from "./button"; + +interface Props { + community: Community | Jsonify; +} + +function CommunityMod({ community }: Props) { + const { user } = useGlobalCtx(); + const fetcher = useFetcher(); + + const actions: { title: string; id: string }[] = []; + + if (community.status === "pending-approval") { + actions.push({ title: "Approve", id: "approve" }); + } + + function handleAction(action: string) { + switch (action) { + case "approve": { + fetcher.submit(JSON.stringify({ action }), { + encType: "application/json", + method: "POST", + action: `/communities/${community.handle}/mod`, + }); + } + } + } + + if (actions.length === 0) { + return null; + } + + if (user?.role !== "moderator") return null; + + return ( +
+
community mod
+ +
+ {actions.map((action) => ( + + ))} +
+
+ ); +} + +export { CommunityMod }; diff --git a/client/app/components/crowdsource-notice.tsx b/client/app/components/crowdsource-notice.tsx index ff2b941..b0182ed 100644 --- a/client/app/components/crowdsource-notice.tsx +++ b/client/app/components/crowdsource-notice.tsx @@ -2,7 +2,7 @@ function CrowdsourceNotice() { return (
-
+
Crowdsourcing

diff --git a/client/app/components/error-boundary.tsx b/client/app/components/error-boundary.tsx index 00f7427..71abbde 100644 --- a/client/app/components/error-boundary.tsx +++ b/client/app/components/error-boundary.tsx @@ -73,7 +73,7 @@ function ErrorBoundary() {

- + Go Home
diff --git a/client/app/components/file-menu.tsx b/client/app/components/file-menu.tsx index 3bc8d12..927135b 100644 --- a/client/app/components/file-menu.tsx +++ b/client/app/components/file-menu.tsx @@ -1,10 +1,13 @@ import { Prisma } from "@prisma/client"; import { useFetcher } from "@remix-run/react"; +import { Jsonify } from "type-fest"; import { useGlobalCtx } from "~/lib/global-ctx"; import { DropdownMenu } from "./dropdown-menu"; +type RepositoryFile = Prisma.RepositoryGetPayload<{ include: { user: true } }>; + interface Props { - file: Prisma.RepositoryGetPayload<{ include: { user: true } }>; + file: RepositoryFile | Jsonify; } function FileMenu({ file }: Props) { diff --git a/client/app/components/lesson-form.tsx b/client/app/components/lesson-form.tsx index ca7f98f..e384e58 100644 --- a/client/app/components/lesson-form.tsx +++ b/client/app/components/lesson-form.tsx @@ -1,14 +1,14 @@ +import { useLoaderData, useParams, useSubmit } from "@remix-run/react"; +import dayjs from "dayjs"; import React from "react"; -import { LargeSelect } from "./large-select"; -import { Input } from "./input"; import { FieldValues, useForm, useFormContext } from "react-hook-form"; -import { useAsyncFetcher } from "~/lib/use-async-fetcher"; -import { Button } from "./button"; -import dayjs from "dayjs"; -import { useLoaderData, useParams, useSubmit } from "@remix-run/react"; import { days } from "~/lib/days"; -import { AddLessonLoader } from "~/routes/timetable_.$year.$programme.$level.$sem.$day.add"; import { isBefore } from "~/lib/time"; +import { useAsyncFetcher } from "~/lib/use-async-fetcher"; +import { AddLessonLoader } from "~/routes/timetable_.$year.$programme.$level.$sem.$day.add"; +import { Button } from "./button"; +import { Input } from "./input"; +import { LargeSelect } from "./large-select"; interface Props { courses: { id: number; code: string; name: string }[]; @@ -120,7 +120,7 @@ function LessonForm({
-
You're adding a lesson +
You're adding a lesson for the above
@@ -220,7 +220,7 @@ function LessonForm({
diff --git a/client/app/components/navbar.tsx b/client/app/components/navbar.tsx index d08aa48..673ba93 100644 --- a/client/app/components/navbar.tsx +++ b/client/app/components/navbar.tsx @@ -1,8 +1,9 @@ -import { Link, NavLink } from "@remix-run/react"; +import { Link, NavLink, useLocation } from "@remix-run/react"; import clsx from "clsx"; import { useGlobalCtx } from "~/lib/global-ctx"; import { Avatar } from "./avatar"; import { Username } from "./username"; +import React from "react"; const links = [ { @@ -15,15 +16,30 @@ const links = [ href: "/timetable", icon: "i-lucide-calendar-range", }, + { + title: "Library", + href: "/library", + icon: "i-lucide-library-square", + }, { title: "Events", href: "/events", icon: "i-lucide-sparkle", }, { - title: "Library", - href: "/library", - icon: "i-lucide-library-square", + title: "Communities", + href: "/communities", + icon: "i-lucide-users-round", + }, + { + title: "Marketplace", + href: "/market", + icon: "i-lucide-shopping-bag", + }, + { + title: "Games", + href: "/games", + icon: "i-lucide-gamepad-2", }, ]; @@ -42,29 +58,6 @@ function Navbar() {
- -
{Boolean(user) && ( { + setShowMore(false); + }, [location.pathname]); + return ( -
+
+
+
    + {links.slice(3).map((link) => ( +
  • + + clsx( + "px-2 py-1 block rounded-lg flex items-center gap-4 hover:bg-zinc-100 dark:hover:bg-neutral-800 group text-secondary", + { "!bg-blue-600 !text-white is-active": isActive }, + ) + } + to={link.href} + > +
    {link.title}
    + +
    +
    +
    + +
  • + ))} +
+
); } -export { BottomNav, Navbar }; +function SideNav() { + return ( +
    + {links.map((link) => ( +
  • + + clsx( + "px-2 py-1 hover:bg-zinc-100 dark:hover:bg-neutral-800 rounded-full font-medium flex items-center gap-2 transition-[background] duration-200", + { + "!bg-zinc-200 !dark:bg-neutral-800": isActive, + "text-secondary": !isActive, + }, + ) + } + > +
    {link.title} + +
  • + ))} +
+ ); +} + +export { BottomNav, Navbar, SideNav }; diff --git a/client/app/components/post-content.tsx b/client/app/components/post-content.tsx index 54e19a8..a1d1b89 100644 --- a/client/app/components/post-content.tsx +++ b/client/app/components/post-content.tsx @@ -1,17 +1,20 @@ import { Media, Prisma } from "@prisma/client"; +import { Link } from "@remix-run/react"; +import React from "react"; +import { Jsonify } from "type-fest"; import { Avatar } from "./avatar"; import { Content } from "./content"; import { MediaItem } from "./media-item"; +import { MediaPreview } from "./media-preview"; import { PostMenu } from "./post-menu"; import { PostTime } from "./post-time"; import { Tags } from "./tags"; import { Username } from "./username"; import { Votes } from "./votes"; -import { Jsonify } from "type-fest"; -import { MediaPreview } from "./media-preview"; -import React from "react"; -type Post = Prisma.PostGetPayload<{ include: { user: true; media: true } }> & { +type Post = Prisma.PostGetPayload<{ + include: { user: true; media: true; community: true }; +}> & { vote?: boolean; }; @@ -52,6 +55,15 @@ function PostContent({ post }: Props) {
+ {post.community && ( + +
+
+ {post.community.name} +
+ + )} + {post.media.length > 0 && (
{post.media.map((media) => ( diff --git a/client/app/components/post-input.tsx b/client/app/components/post-input.tsx index c23d1bb..94e95b6 100644 --- a/client/app/components/post-input.tsx +++ b/client/app/components/post-input.tsx @@ -1,14 +1,16 @@ -import { FieldValues, useForm } from "react-hook-form"; -import { Button } from "./button"; -import { useFetcher } from "react-router-dom"; import { Post } from "@prisma/client"; -import React from "react"; -import { FileSelectItem } from "./file-select-item"; import clsx from "clsx"; +import React from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { useFetcher } from "react-router-dom"; +import { Jsonify } from "type-fest"; +import { useGlobalCtx } from "~/lib/global-ctx"; import { uploadMedia } from "~/lib/upload-media"; import { AudioRecorder } from "./audio-recorder"; -import { useGlobalCtx } from "~/lib/global-ctx"; -import { TagSelect } from "./tag-select"; +import { Button } from "./button"; +import { Content } from "./content"; +import { FileInput } from "./file-input"; +import { FileSelectItem } from "./file-select-item"; import { DEFAULT_SELECTIONS, SelectionId, @@ -16,18 +18,18 @@ import { TagInput, stringifySelections, } from "./tag-input"; -import { Content } from "./content"; -import { FileInput } from "./file-input"; -import { Jsonify } from "type-fest"; +import { TagSelect } from "./tag-select"; interface Props { level?: number; parent?: Post | Jsonify; + dataExtra?: Record; + disabled?: boolean; } const ATTACHMENT_LIMIT = 5 * 1024 * 1024; // 5MB limit -function PostInput({ level = 0, parent }: Props) { +function PostInput({ disabled, level = 0, parent, dataExtra }: Props) { const { getValues, handleSubmit, register, setValue, watch, reset } = useForm( { defaultValues: { @@ -77,10 +79,12 @@ function PostInput({ level = 0, parent }: Props) { parentId: parent?.id, media, tags: stringifiedTags, + ...dataExtra, }), { encType: "application/json", method: "POST", + action: !parent ? "/discussions" : undefined, }, ); } @@ -266,7 +270,7 @@ function PostInput({ level = 0, parent }: Props) {
-
+ {post.community && ( +
+
+ {post.community.name} +
+ )} + {level < 2 && (
@@ -193,3 +202,4 @@ function SubComment({ post }: { post: Props["post"] }) { export { PostItem }; export type { Props as PostItemProps }; + diff --git a/client/app/components/timetable-save-to-calendar.tsx b/client/app/components/timetable-save-to-calendar.tsx index 6ca3c6c..84efd58 100644 --- a/client/app/components/timetable-save-to-calendar.tsx +++ b/client/app/components/timetable-save-to-calendar.tsx @@ -72,7 +72,7 @@ function TimetableSaveToCalender() { Cancel - Download ics file + Download ics file
diff --git a/client/app/lib/check-mod.ts b/client/app/lib/check-mod.ts new file mode 100644 index 0000000..2293397 --- /dev/null +++ b/client/app/lib/check-mod.ts @@ -0,0 +1,18 @@ +import { json } from "@remix-run/node"; +import { checkAuth } from "./check-auth"; +import { prisma } from "./prisma.server"; + +async function checkMod(request: Request) { + const userId = await checkAuth(request); + const user = await prisma.user.findFirst({ + where: { id: userId, role: "moderator" }, + }); + + if (!user) { + throw json({}, { status: 403 }); + } + + return user; +} + +export { checkMod }; diff --git a/client/app/lib/create-post.ts b/client/app/lib/create-post.ts index 3fcf647..82e8255 100644 --- a/client/app/lib/create-post.ts +++ b/client/app/lib/create-post.ts @@ -15,6 +15,7 @@ async function createPost(data: Record, userId: number) { people: 1, tags: data.tags, path, + communityId: data.communityId, }, }); diff --git a/client/app/lib/create-tags-query.ts b/client/app/lib/create-tags-query.ts index d607c50..e307aec 100644 --- a/client/app/lib/create-tags-query.ts +++ b/client/app/lib/create-tags-query.ts @@ -11,9 +11,9 @@ function createTagsQuery(tagsParam: Record = {}) { }, ); - const tagsFilter = tags.length ? { AND: tags } : {}; + const tagsFilter = tags.length ? tags : []; return tagsFilter } -export { createTagsQuery } \ No newline at end of file +export { createTagsQuery }; diff --git a/client/app/lib/ellipsize.ts b/client/app/lib/ellipsize.ts new file mode 100644 index 0000000..23fcc12 --- /dev/null +++ b/client/app/lib/ellipsize.ts @@ -0,0 +1,8 @@ +function ellipsize(str: string, length = 48) { + if (str.length > length) { + return `${str.substring(0, length)}...`; + } + return str; +} + +export { ellipsize }; diff --git a/client/app/lib/get-moderators.ts b/client/app/lib/get-moderators.ts new file mode 100644 index 0000000..2d3a9c9 --- /dev/null +++ b/client/app/lib/get-moderators.ts @@ -0,0 +1,10 @@ +import { prisma } from "./prisma.server"; + +async function getModerators() { + return await prisma.user.findMany({ + where: { role: "moderator" }, + select: { id: true }, + }); +} + +export { getModerators }; diff --git a/client/app/lib/username-regex.tsx b/client/app/lib/username-regex.tsx new file mode 100644 index 0000000..55a8b70 --- /dev/null +++ b/client/app/lib/username-regex.tsx @@ -0,0 +1,4 @@ +const USERNAME_REGEX = /^(?=[a-zA-Z0-9._]{4,20}$)(?!.*[_.]{2})[^_.].*[^_.]$/g; + + +export { USERNAME_REGEX }; diff --git a/client/app/root.tsx b/client/app/root.tsx index f6cc444..1b37238 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -1,6 +1,7 @@ import "@unocss/reset/tailwind.css"; import "./style.css"; +import { User } from "@prisma/client"; import { cssBundleHref } from "@remix-run/css-bundle"; import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import { @@ -12,18 +13,26 @@ import { ScrollRestoration, json, useLoaderData, + useLocation, useRevalidator, } from "@remix-run/react"; -import { BottomNav, Navbar } from "./components/navbar"; +import clsx from "clsx"; +import React from "react"; +import { CommonHead } from "./components/common-head"; import { Footer } from "./components/footer"; +import { BottomNav, Navbar, SideNav } from "./components/navbar"; import { PendingUI } from "./components/pending-ui"; import { checkAuth } from "./lib/check-auth"; -import { prisma } from "./lib/prisma.server"; import { GlobalCtx } from "./lib/global-ctx"; -import { User } from "@prisma/client"; -import { CommonHead } from "./components/common-head"; +import { prisma } from "./lib/prisma.server"; import { useColorScheme } from "./lib/use-color-scheme"; -import React from "react"; + +const authRoutes = [ + "/login", + "/create-account", + "/forgot-password", + "/reset-password", +]; export const loader = async ({ request }: LoaderFunctionArgs) => { let user: User | undefined | null; @@ -52,6 +61,9 @@ export default function App() { const { user, unreadNotifications } = useLoaderData(); const scheme = useColorScheme(); const revalidator = useRevalidator(); + const location = useLocation() + + const hideNav = authRoutes.includes(location.pathname); React.useEffect(() => { if (scheme === "light") { @@ -103,7 +115,27 @@ export default function App() { - +
+
+
+
+ +
+
+ +
+ +
+
+
diff --git a/client/app/routes/communities.tsx b/client/app/routes/communities.tsx new file mode 100644 index 0000000..f2360c4 --- /dev/null +++ b/client/app/routes/communities.tsx @@ -0,0 +1,94 @@ +import { Link, MetaFunction, json, useLoaderData } from "@remix-run/react"; +import { Anchor } from "~/components/anchor"; +import { Avatar } from "~/components/avatar"; +import { ellipsize } from "~/lib/ellipsize"; +import { useGlobalCtx } from "~/lib/global-ctx"; +import { prisma } from "~/lib/prisma.server"; +import { values } from "~/lib/values.server"; + +export const loader = async () => { + const communities = await prisma.community.findMany({ + where: { status: "activated" }, + orderBy: { name: "asc" }, + }); + + return json({ communities, school: values.meta() }); +}; + +export const meta: MetaFunction = ({ data }) => { + return [ + { title: `Communities | ${data?.school.shortName} ✽ compa` }, + { + name: "description", + content: + "Find people with similar interests with Communities. It's an opportunity to engage with like-minds; start conversations, share ideas and grow together.", + }, + ]; +}; + +export default function Communities() { + const { communities } = useLoaderData(); + const { user } = useGlobalCtx(); + + return ( +
+

Communities

+
+
+

+ Find people with similar interests with Communities. It's an + opportunity to engage with like-minds; start conversations, share + ideas and grow together. +

+ +

+ If you'd like to lead a community, click Start Community to get + started. +

+
+
+
+ {user ? ( + Start a community + ) : ( +
+ You need to{" "} + + log in + {" "} + first to start a community. +
+ )} +
+ +
    + {communities.map((community) => { + return ( +
  • + +
    + + +
    +

    {community.name}

    +

    + Open community • {community.members} members +

    + +

    + {ellipsize(community.description, 60)} +

    +
    +
    + +
  • + ); + })} +
+
+ ); +} diff --git a/client/app/routes/communities_.$slug.members.tsx b/client/app/routes/communities_.$slug.members.tsx new file mode 100644 index 0000000..7664d7f --- /dev/null +++ b/client/app/routes/communities_.$slug.members.tsx @@ -0,0 +1,51 @@ +import { ActionFunctionArgs, json } from "@remix-run/node"; +import { checkAuth } from "~/lib/check-auth"; +import { prisma } from "~/lib/prisma.server"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await checkAuth(request); + const user = await prisma.user.findUnique({ where: { id: userId } }); + + if (request.method !== "POST") { + return json({}, { status: 405 }); + } + + const community = await prisma.community.findFirst({ + where: { handle: params.slug }, + }); + + if (!community) { + return json({}, { status: 404 }); + } + + await prisma.communityMember.create({ + data: { communityId: community.id, userId }, + }); + + const count = await prisma.communityMember.count({ + where: { communityId: community.id }, + }); + + await prisma.community.update({ + where: { id: community.id }, + data: { members: count }, + }); + + const notification = await prisma.notification.create({ + data: { + message: `You have a new member (@${user?.username}) in your community (${community.name}).`, + entityId: community.id, + entityType: "community", + actorId: userId, + }, + }); + + await prisma.notificationSubscriber.create({ + data: { + notificationId: notification.id, + userId: community.createdById, + }, + }); + + return null; +}; diff --git a/client/app/routes/communities_.$slug.mod.tsx b/client/app/routes/communities_.$slug.mod.tsx new file mode 100644 index 0000000..671a4a2 --- /dev/null +++ b/client/app/routes/communities_.$slug.mod.tsx @@ -0,0 +1,56 @@ +import { ActionFunctionArgs, json } from "@remix-run/node"; +import { checkMod } from "~/lib/check-mod"; +import { prisma } from "~/lib/prisma.server"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + if (request.method !== "POST") { + throw json({}, { status: 405 }); + } + + const mod = await checkMod(request); + // [ ]: Track who approved a community. We can check through the notification for now + + const data = await request.json(); + const community = await prisma.community.findFirst({ + where: { handle: params.slug }, + }); + + if (!community) { + throw json({}, { status: 404 }); + } + + switch (data.action) { + case "approve": { + await prisma.community.update({ + where: { id: community.id }, + data: { status: "activated", members: 1 }, + }); + + await prisma.communityMember.create({ + data: { + communityId: community.id, + role: "moderator", + userId: community.createdById, + }, + }); + + const notification = await prisma.notification.create({ + data: { + message: `Congratulations 🎉, your community (${community.name}) has been approved.`, + entityId: community.id, + entityType: "community", + actorId: mod.id, + }, + }); + + await prisma.notificationSubscriber.create({ + data: { + notificationId: notification.id, + userId: community.createdById, + }, + }); + } + } + + return json(null); +}; diff --git a/client/app/routes/communities_.$slug.tsx b/client/app/routes/communities_.$slug.tsx new file mode 100644 index 0000000..db0a5b8 --- /dev/null +++ b/client/app/routes/communities_.$slug.tsx @@ -0,0 +1,108 @@ +import { LoaderFunctionArgs, MetaFunction, json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import React from "react"; +import { CommunityInfo } from "~/components/community-info"; +import { CommunityMod } from "~/components/community-mod"; +import { PostInput } from "~/components/post-input"; +import { PostItem, PostItemProps } from "~/components/post-item"; +import { checkAuth } from "~/lib/check-auth"; +import { checkMod } from "~/lib/check-mod"; +import { prisma } from "~/lib/prisma.server"; +import { values } from "~/lib/values.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const community = await prisma.community.findFirst({ + where: { handle: params.slug }, + }); + + if (!community) { + throw json({}, { status: 404 }); + } + + if (community.status !== "activated") { + // only mods can arrive here + try { + await checkMod(request); + } catch { + throw json({}, { status: 404 }); + } + } + + let userId: number | undefined; + try { + userId = await checkAuth(request); + } catch {} + + const members = await prisma.communityMember.findMany({ + where: { communityId: community.id }, + orderBy: { role: "desc" }, + include: { user: true }, + }); + + const membership = userId + ? await prisma.communityMember.findFirst({ + where: { communityId: community.id, userId }, + }) + : null; + + const posts = await prisma.post.findMany({ + where: { communityId: community.id, parentId: null }, + include: { user: true, media: true, community: false }, + orderBy: { createdAt: "desc" }, + }); + + return json({ community, members, membership, posts, school: values.meta() }); +}; + +export const meta: MetaFunction = ({ data }) => { + if (!data) return []; + + return [ + { + title: `${data.community.name} (+${data.community.handle}) | ${data.school.shortName} ✽ compa`, + }, + { name: "description", content: data.community.description }, + ]; +}; + +export default function Community() { + const { community, membership, posts } = useLoaderData(); + + return ( +
+
+
+ + +
+ +
+ + + +
+ {posts.map((post, i) => ( + + + + {i < posts.length - 1 && ( +
+ )} +
+ ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/client/app/routes/communities_.created.tsx b/client/app/routes/communities_.created.tsx new file mode 100644 index 0000000..0b740e2 --- /dev/null +++ b/client/app/routes/communities_.created.tsx @@ -0,0 +1,15 @@ +import { Anchor } from "~/components/anchor"; + +export default function CommunityCreated() { + return ( +
+

Community registration submitted.

+
+ This will go through a short review process. Please keep an eye on your + notifications for update. +
+ + Go home +
+ ); +} diff --git a/client/app/routes/communities_.new.tsx b/client/app/routes/communities_.new.tsx new file mode 100644 index 0000000..efe98de --- /dev/null +++ b/client/app/routes/communities_.new.tsx @@ -0,0 +1,153 @@ +import { + ActionFunctionArgs, + MetaFunction, + json, + redirect, +} from "@remix-run/node"; +import { useFetcher } from "@remix-run/react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Button } from "~/components/button"; +import { Input } from "~/components/input"; +import { checkAuth } from "~/lib/check-auth"; +import { getModerators } from "~/lib/get-moderators"; +import { prisma } from "~/lib/prisma.server"; +import { USERNAME_REGEX } from "~/lib/username-regex"; +import { values } from "~/lib/values.server"; + +export const loader = async () => { + return json({ school: values.meta() }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + throw json({}, { status: 405 }); + } + + const userId = await checkAuth(request); + const data = await request.json(); + + const community = await prisma.community.create({ + data: { ...data, createdById: userId, status: "pending-approval" }, + }); + + const notification = await prisma.notification.create({ + data: { + message: `A new community (${data.name}) is pending approval.`, + entityId: community.id, + entityType: "community", + actorId: userId, + }, + }); + + const moderators = await getModerators(); + + for (const mod of moderators) { + await prisma.notificationSubscriber.create({ + data: { + notificationId: notification.id, + userId: mod.id, + }, + }); + } + + return redirect("/communities/created?successful=1"); +}; + +export const meta: MetaFunction = ({ data }) => { + return [ + { title: `Start a Community | ${data?.school.shortName} ✽ compa` }, + { name: "description", content: "Put people together and make wonders" }, + ]; +}; + +export default function CreateCommunity() { + const { handleSubmit, register } = useForm(); + const fetcher = useFetcher(); + + async function createCommunity(data: FieldValues) { + fetcher.submit(JSON.stringify(data), { + encType: "application/json", + method: "POST", + }); + } + + return ( +
+

Start a community

+

+ Before proceeding, please make sure the community you're about to create + does not already exist. +

+ +
+
+
+ + + + +