diff --git a/.github/workflows/purge-cache.yml b/.github/workflows/purge-cache.yml new file mode 100644 index 0000000..cbbc768 --- /dev/null +++ b/.github/workflows/purge-cache.yml @@ -0,0 +1,16 @@ +name: Purge cache +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + + - name: Purge cache + uses: jakejarvis/cloudflare-purge-action@master + env: + CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} + CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26d4799 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +.DS_store +/public/build + +/.cache +/build +/public/build +.env +.env.prod diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..8c36342 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# this is needed if using pnpm with electron builder, +# as electron builder needs to copy _all_ node modules, +# and pnpm doesn't normally make them all available + +shamefully-hoist = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7c4655 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 + +FROM node:bookworm-slim as base + +EXPOSE 3000 +ENV NODE_ENV production + +FROM base as deps + +WORKDIR /myapp + +ADD server.mjs ./ +ADD package.json ./ +RUN npm install --include=dev + +FROM base as production-deps + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules +ADD server.mjs ./ +ADD package.json ./ +RUN npm prune --omit=dev + +FROM base as build + +WORKDIR /myapp + +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD . . +RUN npx remix build + +FROM base + +ENV NODE_ENV="production" + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +COPY --from=build /myapp/package.json /myapp/package.json +COPY --from=build /myapp/server.mjs /myapp/server.mjs + +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d30fc86 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Install dependencies (node_modules) + +Run ```npm install``` + +# Setup .env file + +Create a .env file in the project root with the following: + +``` +X_WICKED_HEADER='something' +API_URL='http://localhost:3003/v1' +HOT_URL='http://localhost:3005/v1' +``` + +X_WICKED_HEADER is a header used to identify clients connecting to the API. For running locally you can use any value, but for connecting to the production environment - Cloudflare will block requests without a valid header. + +API_URL is the Auth server (usually port 3003 locally). + +HOT_URL is the Hot server (usually port 3005 locally). + +# Running the app locally + +```npx remix dev``` + +You should see a Remix server running locally on port 3000 + +# Creating a new user + +In order to create an account you'll need to create a new record in the beta_users table (assuming you have a MySQL server running and have run go migrate to setup the tables). + +Create a new entry in beta_users with your email address, with redeemed set to 0, and enabled set to 1. + +Now you can navigate your browser to http://localhost:3000/signup and create your user. + +# Building web app app: + +``` +npx remix dev +``` + +# Building electron app: + +``` +npm run desktop-dev +``` + +# Installing ES modules + +``` +npx rmx-cli get-esm-packages framer-motion +``` + +Then modify remix.config diff --git a/app/api/sitemap.server.jsx b/app/api/sitemap.server.jsx new file mode 100644 index 0000000..4d0866a --- /dev/null +++ b/app/api/sitemap.server.jsx @@ -0,0 +1,14 @@ +import { hotRequest } from "../wikid.server"; +import env from "../environment.server"; + +const sitemap = async () => { + const request = { + url: `${env.readHotUrl}/internal/sitemap`, + method: 'get', + postData: null, + jwt: null + } + return hotRequest(request); +} + +export default sitemap; diff --git a/app/components/channel-feed.jsx b/app/components/channel-feed.jsx new file mode 100644 index 0000000..622e308 --- /dev/null +++ b/app/components/channel-feed.jsx @@ -0,0 +1,334 @@ +import { useEffect, useRef, useState, useContext, useCallback } from 'react' +import { useFetcher } from "@remix-run/react"; +import ChannelReplybox from "./channel-replybox"; +import ChannelMessage from "./channel-message"; +import ChannelMessageSkeleton from "./channel-message-skeleton"; +import { useStateWithCallbackLazy } from "./feed-helpers"; +import Prando from 'prando'; +import WebsocketContext from './websocket-context'; + +import './styles.channel-feed.css'; + +const skeletonMessageHeight = 100; + +export default function ChannelFeed(props) { + const { channel, community, files, setFiles, dragging } = props; + + const { sendJsonMessage, lastJsonMessage } = useContext(WebsocketContext); + + const me = channel.user; + + const channelId = channel.id; + + const messagesFetcher = useFetcher({ key: `messages-${channelId}` }); + const channelFetcher = useFetcher({ key: `channel-${channelId}` }); + + const [remainingMessages, setRemainingMessages] = useState(channel.remaining_messages); + + const shownSekeletons = Math.min(remainingMessages, 50); + const moreRemaining = remainingMessages > 0; + + const [fetchingMore, setFetchingMore] = useState(false); + const [page, setPage] = useState(0); + + const feedRef = useRef(null); + const pageTops = useRef([]); + const end = useRef(null); + const gap = useRef(null); + const [prevScrollTop, setPrevScrollTop] = useState(null); + const [messages, setMessages] = useStateWithCallbackLazy([]); + + const [replyingTo, setReplyingTo] = useState(null); + + const handleNewSocketMessage = useCallback((newMessageJson) => { + if (newMessageJson?.topic == channel.id) { + try { + const update = JSON.parse(newMessageJson.message); + + if (messages.length > 0) { + + const idx = messages.map((m) => m.id).indexOf(update.id); + + if (idx >= 0) { + + let mp = [...messages]; + + mp[idx] = update; + + setMessages(mp, () => { + if (update.user.handle == channel.user.handle) { + return + } + const feed = feedRef.current; + feed.scrollTop = feed.scrollHeight; + }); + } else { + const last = messages[messages.length - 1]; + + if (last.user.handle !== channel.user.handle) { + setMessages([...messages, update], () => { + const feed = feedRef.current; + feed.scrollTop = feed.scrollHeight; + }); + } else { + let mp = [...messages]; + mp[messages.length - 1] = update; + setMessages(mp); + } + } + } else { + setMessages([update], () => { + if (update.user.handle == channel.user.handle) { + return + } + const feed = feedRef.current; + feed.scrollTop = feed.scrollHeight; + }); + } + } catch (e) { + console.error("encountered an error json decoding"); + } + } + }, [messages, channel]); + + useEffect(() => { + handleNewSocketMessage(lastJsonMessage); + }, [lastJsonMessage]); + + const addMessageOptimistically = (data) => { + let newMessages = [...messages]; + + const { text, parentMessage, files, user, optimisticUuid } = data; + + if (newMessages.length > 0) { + const idx = newMessages.length - 1; + const last = newMessages[idx]; + + if (last.user.handle == user.handle && !parentMessage && files.length == 0 && (!last.files || last.files.length == 0)) { + newMessages[idx] = { + id: last.id, + optimistic: true, + optimisticUuid: optimisticUuid, + created_at: last.created_at, + updated_at: last.updated_at, + text: `${last.text}\n${text}`, + user: user, + reactions: last.reactions, + edited: last.edited, + parent: last.parent, + files: last.files, + }; + } else { + newMessages = [...messages, { + id: Date().toString(), + optimistic: true, + optimisticUuid: optimisticUuid, + text: text, + user: user, + reactions: {}, + edited: false, + parent: parentMessage, + files: files, + }]; + } + } else { + newMessages = [{ + id: Date().toString(), + optimistic: true, + optimisticUuid: optimisticUuid, + text: text, + user: user, + reactions: {}, + edited: false, + parent: parentMessage, + files: files, + }]; + } + + setMessages(newMessages, () => { + const feed = feedRef.current; + feed.scrollTop = feed.scrollHeight; + }); + }; + + useEffect(() => { + if (!!channelId) { + sendJsonMessage({ "type": "subscribe", "topic": channelId }); + } + }, [channelId]); + + useEffect(() => { + setReplyingTo(null); + setPrevScrollTop(null); + setPage(0); + setRemainingMessages(channel.remaining_messages); + setMessages(channel.messages, () => { + const feed = feedRef.current; + feed.scrollTop = feed.scrollHeight; + }); + }, [channelId]); + + useEffect(() => { + if (!fetchingMore) { + return; + } + + if (!messagesFetcher.data?.channel) { + return + } + + const [response] = messagesFetcher.data.channel; + + if (!!response?.messages) { + + const newMessages = response.messages; + const remaining = response.remaining_messages; + + setRemainingMessages(remaining); + + setMessages((prev) => [...newMessages, ...prev], () => { + if (!!pageTops.current && pageTops.current.length > 1) { + pageTops.current[1]?.scrollIntoView({ behavior: "instant" }); + } + }); + + setFetchingMore(false); + } + + }, [messagesFetcher, fetchingMore]); + + useEffect(() => { + const optimisticUuid = channelFetcher.data?.optimisticUuid; + + if (!optimisticUuid) { + return + } + + const response = channelFetcher.data; + + if (!!response?.id) { + setMessages((prev) => { + let i = 0; + const update = [...prev]; + while (i < update.length) { + const msg = update[i]; + if (msg.optimisticUuid == optimisticUuid) { + msg.optimistic = false; + msg.id = msg.id; + update[i] = msg; + } + i++; + } + return update; + }, () => { }); + } + + }, [channelFetcher]); + + useEffect(() => { + if (page == 0) { + return; + } + + const data = { + __action: "fetch_more_messages", + page: page + 1, + communityHandle: community.handle, + channelHandle: channel.handle, + } + + messagesFetcher.submit(data, { + method: "post", + }); + + }, [page]); + + return ( + <> +
{ e.preventDefault() }} className='wk-channel-feed' onScroll={_ => { + + const feed = feedRef.current; + + if (feed.scrollHeight <= feed.clientHeight) { + // Not scrollable. + return; + } + + if (!moreRemaining) { + // There's no more messages + return; + } + + const threshold = skeletonMessageHeight * shownSekeletons; + const scrollPosition = feed.scrollTop; + + if (!prevScrollTop) { + setPrevScrollTop(scrollPosition); + return; + } + + if (prevScrollTop < scrollPosition) { + return; + } + + if (scrollPosition <= threshold && !fetchingMore) { + setFetchingMore(true); + setPage((v) => v + 1); + } + }}> + {remainingMessages > 0 && Array.apply(null, { length: shownSekeletons }).map((_, i) => { + let rng = new Prando(i); + rng.skip(i); + let num = rng.nextInt(40, 100); + return ( + + + + ); + })} + {messages.map((e, i) => { + const isEnd = i == (messages.length - 1); + const isReplyingTo = !!replyingTo; + if (i % 50 == 0) { + return ( + pageTops.current[i / 50] = ref} key={`cm-${e.id}-${i}`}> + + + ); + } else { + return ( + + + + ); + } + })} +
+ +
+
+ +
+ + + ); +} diff --git a/app/components/channel-header.jsx b/app/components/channel-header.jsx new file mode 100644 index 0000000..5213451 --- /dev/null +++ b/app/components/channel-header.jsx @@ -0,0 +1,24 @@ +import { Flex, Text, TextField } from '@radix-ui/themes'; +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; + +import './styles.channel-header.css'; + +export default function ChannelHeader(props) { + const { channel } = props; + return ( +
{e.preventDefault() }}> + + {channel.name} +
+ + + + + + + + + +
+ ); +} diff --git a/app/components/channel-message-skeleton.jsx b/app/components/channel-message-skeleton.jsx new file mode 100644 index 0000000..edd130f --- /dev/null +++ b/app/components/channel-message-skeleton.jsx @@ -0,0 +1,30 @@ +import { Avatar, Text, Flex } from '@radix-ui/themes'; + +import './styles.channel-message-skeleton.css'; +import './styles.channel-message.css'; + +export default function ChannelMessageSkeleton(props) { + const { size } = props; + return ( +
+ + + + +   + + +   + + + +
+ ); +} diff --git a/app/components/channel-message.jsx b/app/components/channel-message.jsx new file mode 100644 index 0000000..b990494 --- /dev/null +++ b/app/components/channel-message.jsx @@ -0,0 +1,403 @@ +import { Avatar, Text, Flex, ContextMenu, TextArea, Button, IconButton, Popover, ScrollArea, Theme } from '@radix-ui/themes'; +import dayjs from 'dayjs'; +import { friendlyName, avatarFallback } from "./helpers"; +import { useState, useCallback, useEffect } from 'react'; +import { ResetIcon, HeartFilledIcon, Pencil1Icon, FileIcon } from '@radix-ui/react-icons'; +import { useFetcher } from "@remix-run/react"; +import Twemoji from "./twemoji"; +import { Image } from "@unpic/react"; +import * as Dialog from '@radix-ui/react-dialog'; +// import ReactPlayer from 'react-player'; +// import { ClientOnly } from "remix-utils/client-only"; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import JsFileDownloader from 'js-file-downloader'; + +import './styles.channel-message.css'; + +const emoji = ['\u{1F600}', '\u{1F606}', '\u{1F605}', '\u{1F929}', '\u{1F618}', '\u{1F60B}', '\u{1F614}', '\u{1F922}', + '\u{1F975}', '\u{1F976}', '\u{1F974}', '\u{1F60E}', '\u{1F4AF}']; + +function MessageParent(props) { + const { parent } = props; + + const name = friendlyName(parent.user.name, parent.user.handle); + const fb = avatarFallback(name); + + return ( + + +
+ {!!parent.user.avatar_url ? + {parent.user.handle} + : + } + + @{parent.user.handle} + {parent.text.replace(/(\r\n|\n|\r)/gm, " ")} + ); +} + +export default function ChannelMessage(props) { + const { me, message, community, setReplyingTo } = props; + + const editMessageFetcher = useFetcher({ key: `edit-message-${community.handle}` }); + + const name = friendlyName(message.user.name, message.user.handle); + const fb = avatarFallback(name); + const color = message.user?.powerful_role?.color; + const [editing, setEditing] = useState(false); + const [text, setText] = useState(editMessageFetcher.data?.text ?? message.text); + const [edited, setEdited] = useState(!!editMessageFetcher.data?.text || message.edited); + const [emojiReactions, setEmojiReactions] = useState(false); + const [reactions, setReactions] = useState(message.reactions ?? {}); + const [files, setFiles] = useState(message.files ?? []); + + useEffect(() => { + setText(message.text); + setEdited(message.edited); + setReactions(message.reactions ?? {}); + setFiles(message.files ?? []); + }, [message]); + + const rk = Object.keys(reactions); + + const isMine = me?.handle == message.user.handle; + + const handleCopyText = useCallback(() => { + if (typeof document !== "undefined") { + + let selectedText = ""; + + if (window.getSelection) { + selectedText = window.getSelection().toString(); + } else if (document.getSelection) { + selectedText = document.getSelection().toString(); + } else if (document.selection) { + selectedText = document.selection.createRange().text; + } + + selectedText = selectedText?.trim(); + + if (!selectedText || selectedText.length == 0) { + selectedText = text; + } + + navigator.clipboard.writeText(selectedText); + } + }, [text]); + + const handleEdit = useCallback(() => { + setEditing(false); + setEdited(true); + const data = { + __action: "edit_message", + communityHandle: community.handle, + messageId: message.id, + text: text, + }; + editMessageFetcher.submit(data, { + method: "post", + }); + }, [message, text, community]); + + const handleReact = useCallback((reaction) => { + setEmojiReactions(false); + + const data = { + __action: "react_to_message", + communityHandle: community.handle, + messageId: message.id, + reaction: reaction, + }; + editMessageFetcher.submit(data, { + method: "post", + }); + + let f = reactions[reaction]; + + if (f == null) { + const m = {} + + m[reaction] = [{ + user_id: me.id, + user_handle: me.handle + }]; + + setReactions({ + ...reactions, + ...m, + }) + } else { + + let adding = true; + + for (let i = 0; i < f.length; i++) { + if (f[i].user_handle == me.handle) { + adding = false; + } + } + + const cp = { ...reactions }; + + if (adding) { + cp[reaction] = [...cp[reaction], { + user_id: me.id, + user_handle: me.handle + }]; + } else { + f.pop(); + + if (f.length == 0) { + delete cp[reaction]; + } else { + cp[reaction] = f; + } + } + + setReactions(cp); + } + + }, [message, reactions, community, me]); + + const handleUserKeyPress = useCallback(event => { + const { key } = event; + if (key === "Escape" && editing) { + setEditing(false); + setText(message.text); + } + }, [message, editing]); + + const handleReply = useCallback(() => { + setReplyingTo({ + handle: message.user.handle, + messageId: message.id, + color: message.user?.powerful_role?.color, + message: message + }); + }, [message]); + + useEffect(() => { + window.addEventListener("keydown", handleUserKeyPress); + return () => { + window.removeEventListener("keydown", handleUserKeyPress); + }; + }, [handleUserKeyPress]); + + const type = ((message.parent?.user.handle == me?.handle) && !!me) ? "active" : "normal"; + + const messageText = + +
+ setEmojiReactions(false)} modal> + + {isMine && setEditing(true)}>} + setEmojiReactions(true)}> + {!isMine && } + + + + {!!message.parent && } + + {!!message.user.avatar_url ? + {message.user.handle} + : + } + + + {name} + {dayjs(message.created_at).format('MM/DD/YYYY hh:mm A')} + + {!!text && text.length > 0 && + + {text} + + } + {!!message.optimistic && + sending + } + {edited && + edited + } + {files.length > 0 && + {files.map((e) => { + if (e.mime_type.includes("video")) { + return {e.name}; + } else if (e.mime_type.includes("image")) { + return + + {e.name} + + + { e.preventDefault() }} /> + + + + + + {e.name} + + + { + new JsFileDownloader({ url: e.url, filename: e.name }) + }}>Download + + + + + + + + + + + + + ; + } else { + return ; + } + })} + } + + + + + + + + {emoji.map((e) => { + return ; + })} + + + + +
+ {rk.length > 0 && + {rk.map((e) => { + return ; + })} + } +
+ ; + + const messageEditor = +
+ + + {!!message.user.avatar_url ? + {message.user.handle} + : + } + + + {name} + {dayjs(message.created_at).format('MM/DD/YYYY hh:mm A')} + + + + + + + + + +
; + + return ( + + + {editing ? messageEditor : messageText} + + + setEmojiReactions(true)}>Add reactions + {isMine ? + <> + setEditing(true)}>Edit + Copy text + + Delete message + : + <> + Reply to + Copy text + + Report message + + } + + + ); +} diff --git a/app/components/channel-replybox.jsx b/app/components/channel-replybox.jsx new file mode 100644 index 0000000..8ea9a26 --- /dev/null +++ b/app/components/channel-replybox.jsx @@ -0,0 +1,236 @@ +import { IconButton, Text, TextArea, Flex, Button } from '@radix-ui/themes'; +import { useFetcher } from "@remix-run/react"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { CrossCircledIcon, CameraIcon, TrashIcon, FileIcon, PlusIcon } from '@radix-ui/react-icons'; +import { Image } from "@unpic/react"; +import useAutosizeTextArea from './useAutosizeTextArea'; +import './styles.channel-replybox.css'; +import { v4 as uuidv4 } from 'uuid'; + +function getVideoCover(file, seekTo = 0.0) { + return new Promise((resolve, reject) => { + const videoPlayer = document.createElement('video'); + videoPlayer.setAttribute('src', URL.createObjectURL(file)); + videoPlayer.load(); + videoPlayer.addEventListener('error', (ex) => { + reject("error when loading video file", ex); + }); + videoPlayer.addEventListener('loadedmetadata', () => { + if (videoPlayer.duration < seekTo) { + reject("video is too short."); + return; + } + setTimeout(() => { + videoPlayer.currentTime = seekTo; + }, 200); + videoPlayer.addEventListener('seeked', () => { + const canvas = document.createElement("canvas"); + canvas.width = videoPlayer.videoWidth; + canvas.height = videoPlayer.videoHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height); + ctx.canvas.toBlob(blob => resolve(blob), "image/jpeg", 0.75); + }); + }); + }); +} + +export default function ChannelReplybox(props) { + + const { channel, community, replyingTo, setReplyingTo, files, setFiles, dragging, addMessageOptimistically } = props; + + const [filePreviews, setFilePreviews] = useState([]); + + const [text, setText] = useState(""); + const channelFetcher = useFetcher({ key: `channel-${channel.id}` }); + + const [rows, setRows] = useState(1); + const hiddenFileInput = useRef(null); + + const textAreaRef = useRef(null); + + useAutosizeTextArea(textAreaRef.current, text); + + const canMessage = community?.permissions?.send_messages ?? false; + + const restoreDefaults = () => { + setFiles([]); + setRows(1); + setText(""); + } + + const handleCreateMessage = useCallback(() => { + const trimmed = text.trim(); + + if ((trimmed.length == 0 && files.length == 0) || !community) { + setText(""); + return; + } + + const form = new FormData(); + + form.append("__action", "create_message"); + + const optimisticUuid = uuidv4(); + + form.append("optimisticUuid", optimisticUuid); + + const localFiles = files.map(f => { + return { + name: f.name, + mime_type: f.type, + thumbnail_url: URL.createObjectURL(f), + url: URL.createObjectURL(f) + } + }); + + form.append('input', JSON.stringify({ + text: trimmed, + channelId: channel.id, + communityHandle: community.handle, + parentId: replyingTo?.messageId, + parentMessage: replyingTo?.message, + localFiles: localFiles, + })); + + for (var file of files) { + form.append('files', file); + } + + setReplyingTo(null); + + channelFetcher.submit(form, { + encType: "multipart/form-data", + action: "/z/create-message", + method: "post", + navigate: false, + fetcherKey: `channel-${channel.id}` + }); + + addMessageOptimistically({ + user: channel.user, + text: trimmed, + channelId: channel.id, + communityHandle: community.handle, + parentId: replyingTo?.messageId, + parentMessage: replyingTo?.message, + files: localFiles ?? [], + optimisticUuid: optimisticUuid, + }) + + restoreDefaults(); + }, [text, channel, community, replyingTo, files]); + + const handleCancelReply = useCallback(() => { + setReplyingTo(null); + }, []); + + const handleClickFileUpload = useCallback(() => { + hiddenFileInput.current.click(); + }, []); + + const handleFilePick = useCallback(async (event) => { + setFiles([...event.target.files]); + }, []); + + const removeFile = useCallback((file) => { + const cp = [...files].filter(x => x.name != file.name); + setFiles(cp); + }, [files, setFiles]); + + useEffect(() => { + if (text.length == 0 && rows != 3) { + setRows(1); + } + }, [text, rows]); + + useEffect(() => { + + const mapped = files.map((file) => new Promise((resolve, reject) => { + if (file.type.includes("image/")) { + resolve({ name: file.name, url: URL.createObjectURL(file) }) + } else if (file.type.includes("video/")) { + getVideoCover(file, 0.0) + .then((image) => resolve({ name: file.name, url: URL.createObjectURL(image) })) + .catch(() => reject("Couldn't generate thumbnail for video")) + } else { + resolve({ name: file.name }) + } + }) + ); + + Promise.all(mapped).then((r) => setFilePreviews(r)); + + }, [files]); + + return ( + + {(filePreviews.length > 0 || dragging) && + {dragging && Drop your files to attach} + {filePreviews.map((file) => { + if (!!file.url) { + return
+ removeFile(file)} radius="medium" color='red' className='wk-reply-image-remove' aria-label="Remove attachment"> + + + {file.name} +
; + } + return
+ removeFile(file)} radius="medium" color='red' className='wk-reply-image-remove' aria-label="Remove attachment"> + + + +
; + })} +
} +
+ {!!replyingTo &&
+ Replying to + @{replyingTo.handle} + + +
} + + + + + +
+
+ ); +} diff --git a/app/components/channel-users.jsx b/app/components/channel-users.jsx new file mode 100644 index 0000000..f9a9bea --- /dev/null +++ b/app/components/channel-users.jsx @@ -0,0 +1,114 @@ +import { Text, Flex, Avatar, ContextMenu } from '@radix-ui/themes'; +import { friendlyName, avatarFallback } from "./helpers"; +import { Image } from "@unpic/react"; + +import './styles.channel-users.css'; + +function ChannelUserGroup(props) { + const { users, name, color, me, kickUsers, banUsers } = props; + + if (!users || users.length == 0) { + return
; + } + + return ( + <> + + + {`${name.toUpperCase()}`} - {users.length} + + {users.map((o) => )} + + + ); +} + +function ChannelUser(props) { + const { user, color, me, kickUsers, banUsers } = props; + const name = friendlyName(user.name, user.handle); + const fb = avatarFallback(name); + + const selfMenu = <> + Leave community + ; + + const otherMenu = <> + {!!me ? <> + Report + Block + : <> + Report + } + {(kickUsers || banUsers) && } + {kickUsers && Kick} + {banUsers && Ban} + ; + + const isSelf = (user.handle === me?.handle); + + return ( + + + + {!!user.avatar_url ? + {user.handle} + : + } + {name} + + + + {isSelf ? selfMenu : otherMenu} + + + ); +} + +export default function ChannelUsers(props) { + const { channel, community } = props; + + const prominentRoles = channel.prominent_roles; + const onlineUsers = channel.others_online; + const offlineUsers = channel.others_offline; + const me = channel.user; + const kickUsers = community.permissions.kick_members; + const banUsers = community.permissions.ban_members; + + return ( +
{ e.preventDefault() }}> + {prominentRoles.map((o) => )} + + +
+ ); +} diff --git a/app/components/community-channel.jsx b/app/components/community-channel.jsx new file mode 100644 index 0000000..10c80ce --- /dev/null +++ b/app/components/community-channel.jsx @@ -0,0 +1,81 @@ +import { Heading, Flex } from '@radix-ui/themes'; +import ChannelUsers from './channel-users.jsx'; +import ChannelFeed from './channel-feed.jsx'; +import ChannelHeader from './channel-header.jsx'; +import { useState } from "react"; + +import './styles.community-channel.css'; + +export function CommunityChannelSkeleton() { + return ( +
+
+
{ e.preventDefault() }} /> +
{ e.preventDefault() }} /> +
+
{ e.preventDefault() }} /> +
); +} + +export default function CommunityChannel(props) { + const { channel, community, channelHandle } = props; + + const [files, setFiles] = useState([]); + + const [dragging, setDragging] = useState(false); + + const onDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(true); + } + + const onDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(false); + } + + const onDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(false); + + const { files: droppedFiles } = e.dataTransfer; + + if (!!droppedFiles && droppedFiles.length) { + setFiles([...files, droppedFiles[0]]); + } + } + + if (!!channel && !!community) { + return ( +
+
+ + +
+ +
+ ); + } + + return ( + + This channel doesnt exist 👀 + + ); +} diff --git a/app/components/community-menu.jsx b/app/components/community-menu.jsx new file mode 100644 index 0000000..cbae2e2 --- /dev/null +++ b/app/components/community-menu.jsx @@ -0,0 +1,620 @@ +import { Text, Flex, Box, IconButton, Dialog, Strong, Avatar, Popover, ContextMenu, Button, TextField } from '@radix-ui/themes'; +import { PlusIcon, GearIcon, ChevronDownIcon, ChevronRightIcon } from '@radix-ui/react-icons'; +import { useLoaderData, Link, NavLink, useSubmit, PrefetchPageLinks } from "@remix-run/react"; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useCallback, useState } from 'react'; +import CreateChannelDialog from './create-channel-dialog.jsx'; +import CreateGroupDialog from './create-group-dialog.jsx'; +import EditChannelDialog from './edit-channel-dialog.jsx'; +import EditGroupDialog from './edit-group-dialog.jsx'; +import ConfirmDeleteGroupDialog from './confirm-delete-group-dialog.jsx'; +import ConfirmDeleteChannelDialog from './confirm-delete-channel-dialog.jsx'; +import { wkGhostButton } from './helpers.js'; +import { friendlyName, avatarFallback } from "./helpers"; +import CreateInviteDialog from './create-invite-dialog.jsx'; +import { Image } from "@unpic/react"; + +import './styles.user-menu.css'; + +function TopChannels(props) { + + const { community, channels, manageChannels, me, canInvite, webUrl } = props; + + const [channel, setChannel] = useState(null); + const submit = useSubmit(); + + const [createChannelOpen, setCreateChannelOpen] = useState(false); + const [editChannelOpen, setEditChannelOpen] = useState(false); + const [deleteChannelOpen, setDeleteChannelOpen] = useState(false); + const [createGroupOpen, setCreateGroupOpen] = useState(false); + const [createInviteOpen, setCreateInviteOpen] = useState(false); + + const toggleEditChannelOpen = useCallback(() => { + setEditChannelOpen(v => { + if (v) { + setChannel(null); + } + return !v; + }); + }, []); + + const toggleDeleteChannelOpen = useCallback(() => { + setDeleteChannelOpen(v => { + if (v) { + setChannel(null); + } + return !v; + }); + }, []); + + const toggleCreateChannelOpen = useCallback(() => { setCreateChannelOpen(v => !v); }, []); + const toggleCreateGroupOpen = useCallback(() => { setCreateGroupOpen(v => !v); }, []); + const toggleInviteOpen = useCallback(() => { setCreateInviteOpen((v) => !v); }, []); + + const handleJoinCommunity = useCallback(() => { + const data = { + __action: "join_community", + communityHandle: community.handle + } + submit(data, { + method: "post", + }); + }, [community]); + + const handleMarkAsRead = useCallback((channel) => { + // setCreateGroupOpen(v => !v); + }, []); + + return ( + <> + <> + + setCreateGroupOpen(false)} /> + + + setCreateInviteOpen(false)} webUrl={webUrl} /> + + + <> + + setCreateChannelOpen(false)} /> + + + setEditChannelOpen(false)} /> + + + setDeleteChannelOpen(false)} /> + + + <> + {!!channels && channels.map((c) => )} + + + {!!channels && channels.map((channel) => { + const ur = channel.unread_count > 0; + const navLink = + + {ur && } + {channel.name} +
+ {!!manageChannels && { + e.preventDefault(); + setChannel(channel); + toggleEditChannelOpen(); + }}> + + } + + ; + + return + + {navLink} + + + {!!me ? + handleMarkAsRead(channel)}> + Mark as read + : + + Join community + } + {manageChannels && + <> + { + setChannel(channel); + toggleEditChannelOpen(); + }}> + Edit channel + + + Create channel + + + Create group + + } + {canInvite && + Invite people + } + {manageChannels && { + setChannel(channel); + toggleDeleteChannelOpen(); + }}> + Delete channel + } + + ; + })} + + + ); +} + +function ChannelGroup(props) { + const { canInvite, group, community, manageChannels, me, webUrl } = props; + + const submit = useSubmit(); + + const [open, setOpen] = useState(true); + const [channel, setChannel] = useState(null); + + const [createChannelOpen, setCreateChannelOpen] = useState(false); + const [editChannelOpen, setEditChannelOpen] = useState(false); + const [deleteChannelOpen, setDeleteChannelOpen] = useState(false); + const [createInviteOpen, setCreateInviteOpen] = useState(false); + const [createGroupOpen, setCreateGroupOpen] = useState(false); + + const [editGroupOpen, setEditGroupOpen] = useState(false); + const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false); + + const toggleOpen = useCallback(() => { + if (!!channel || createChannelOpen) { + return; + } + setOpen(v => !v); + }, [channel, createChannelOpen]); + + const toggleCreateGroupOpen = useCallback(() => { setCreateGroupOpen(v => !v); }, []); + + const toggleEditChannelOpen = useCallback(() => { + setEditChannelOpen(v => { + if (v) { + setChannel(null); + } + return !v; + }); + }, []); + + const toggleDeleteChannelOpen = useCallback(() => { + setDeleteChannelOpen(v => { + if (v) { + setChannel(null); + } + return !v; + }); + }, []); + + const toggleCreateChannelOpen = useCallback(() => { setCreateChannelOpen(v => !v); }, []); + const toggleEditGroupOpen = useCallback(() => { setEditGroupOpen(v => !v); }, []); + const toggleConfirmDeleteGroupOpen = useCallback(() => { setConfirmDeleteGroupOpen(v => !v); }, []); + const toggleInviteOpen = useCallback(() => { setCreateInviteOpen((v) => !v); }, []); + + const handleJoinCommunity = useCallback(() => { + const data = { + __action: "join_community", + communityHandle: community.handle + } + submit(data, { + method: "post", + }); + }, [community]); + + const contextMenu = <> + {!!me ? + <> + + Mark as read + + : + <> + + Join community + + } + {manageChannels && + <> + + Edit group + + + Create channel in group + + } + {canInvite && + Invite people + } + {manageChannels && <> + + Delete group + + + + Create group + + } + + + return ( + <> + <> + + setCreateChannelOpen(false)} webUrl={webUrl} /> + + + setCreateGroupOpen(false)} /> + + + setEditChannelOpen(false)} /> + + + setDeleteChannelOpen(false)} /> + + + setCreateInviteOpen(false)} /> + + + <> + + setEditGroupOpen(false)} /> + + + setConfirmDeleteGroupOpen(false)} /> + + + + + + + {open ? + : + } + + {`${group.name.toUpperCase()}`} + +
+ {!!manageChannels && + + + } + + + + {contextMenu} + + + + + + + + {group.channels.map((channel) => { + const ur = channel.unread_count > 0; + return + + + {ur && } + {channel.name} +
+ {!!manageChannels && 0 ? "white" : "gray"} variant="ghost" size="1" onClick={(e) => { + e.preventDefault(); + setChannel(channel); + toggleEditChannelOpen(); + }}> + + } + + + + {!!me ? + + Mark as read + : + + Join community + } + + {manageChannels && <> + { + setChannel(channel); + toggleEditChannelOpen(); + }}> + Edit channel + + + Create channel in group + + } + {canInvite && + Invite people + } + {manageChannels && <> + { + setChannel(channel); + toggleDeleteChannelOpen(); + }}> + Delete channel + + + + Create group + + } + + ; + })} + + + + {contextMenu} + + + + + + + ); +} + +function ChannelsList(props) { + + const { channelGroups, manageChannels, manageCommunity, canInvite, serverOwner, community, me, webUrl } = props; + + const submit = useSubmit(); + + const name = friendlyName(me?.name, me?.handle); + const fb = avatarFallback(name); + + const [createChannelOpen, setCreateChannelOpen] = useState(false); + const [createGroupOpen, setCreateGroupOpen] = useState(false); + const [createInviteOpen, setCreateInviteOpen] = useState(false); + + const toggleCreateChannelOpen = useCallback(() => { setCreateChannelOpen((v) => !v); }, []); + const toggleCreateGroupOpen = useCallback(() => { setCreateGroupOpen((v) => !v); }, []); + const toggleInviteOpen = useCallback(() => { setCreateInviteOpen((v) => !v); }, []); + + const handleLeaveCommunity = useCallback(() => { + const data = { + __action: "leave_community", + communityHandle: community.handle, + } + submit(data, { method: "post" }); + }, [community]); + + const handleJoinCommunity = useCallback(() => { + const data = { + __action: "join_community", + communityHandle: community.handle + } + submit(data, { + method: "post", + }); + }, [community]); + + const [comminityPopoverActive, setComminityPopoverActive] = useState(false); + + return (<> + + setCreateInviteOpen(false)} webUrl={webUrl} /> + + + setCreateGroupOpen(false)} /> + + + setCreateChannelOpen(false)} /> + + + ); +} + +export default function CommunityMenu() { + + const data = useLoaderData(); + + const webUrl = data?.webUrl; + const me = data?.me; + const community = data?.community; + const channelGroups = community?.channel_groups; + + if (!community) { + return null; + } + + let serverOwner = false; + let manageCommunity = false; + let manageChannels = false; + let canInvite = false; + + if (!!me) { + serverOwner = community.server_owner ?? false; + manageCommunity = community.permissions?.manage_community ?? false; + manageChannels = community.permissions?.manage_channels ?? false; + canInvite = community.permissions?.create_invite ?? false; + } + + return ( + + ); +} diff --git a/app/components/community-settings-menu.jsx b/app/components/community-settings-menu.jsx new file mode 100644 index 0000000..18f4b8e --- /dev/null +++ b/app/components/community-settings-menu.jsx @@ -0,0 +1,19 @@ +import { useLoaderData } from "@remix-run/react"; +import CommunitySettingsPanel from './community-settings-panel.jsx'; + +import './styles.user-menu.css'; + +export default function CommunitySettingsMenu() { + + const data = useLoaderData(); + const me = data.me; + const community = data?.community; + + return ( + + ); +} diff --git a/app/components/community-settings-panel.jsx b/app/components/community-settings-panel.jsx new file mode 100644 index 0000000..806c573 --- /dev/null +++ b/app/components/community-settings-panel.jsx @@ -0,0 +1,24 @@ +import { Text, Box } from '@radix-ui/themes'; +import { useLoaderData, Link, NavLink } from "@remix-run/react"; +import { wkGhostButton } from './helpers.js'; + +import './styles.community-settings-panel.css'; + +export default function CommunitySettingsPanel() { + const data = useLoaderData(); + const community = data.community; + + return ( + + ); +} diff --git a/app/components/confirm-delete-channel-dialog.jsx b/app/components/confirm-delete-channel-dialog.jsx new file mode 100644 index 0000000..aead9d1 --- /dev/null +++ b/app/components/confirm-delete-channel-dialog.jsx @@ -0,0 +1,71 @@ +import { Text, Flex, Dialog, Button, Callout } from '@radix-ui/themes'; +import { useFetcher } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function ConfirmDeleteChannelDialog(props) { + const { channel, community, closeDialog } = props; + + const fetcher = useFetcher(); + + const actionData = fetcher.data; + + const [errors, setErrors] = useState(false); + + const isBusy = fetcher.state !== "idle"; + + const handleDelete = useCallback(() => { + if (!community) { + return; + } + + setErrors(null); + + const data = { + __action: "delete_channel", + channelId: channel?.id, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [community, channel]); + + useEffect(() => { + if (!!(actionData?.ok)) { + closeDialog(); + } + + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Confirm delete channel + + + Are you sure you want to delete this channel? + Deleting this channel will also delete all messages and media, forever. + + + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + ); +} diff --git a/app/components/confirm-delete-group-dialog.jsx b/app/components/confirm-delete-group-dialog.jsx new file mode 100644 index 0000000..5f8e40f --- /dev/null +++ b/app/components/confirm-delete-group-dialog.jsx @@ -0,0 +1,72 @@ +import { Text, Flex, Dialog, Button, Callout } from '@radix-ui/themes'; +import { useFetcher } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function ConfirmDeleteGroupDialog(props) { + const { group, community, closeDialog } = props; + + const fetcher = useFetcher(); + + const actionData = fetcher.data; + + const [errors, setErrors] = useState(false); + + const isBusy = fetcher.state !== "idle"; + + const handleDelete = useCallback(() => { + if (!community) { + return; + } + + setErrors(null); + + const data = { + __action: "delete_group", + groupId: group?.id, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [community, group]); + + useEffect(() => { + if (!!(actionData?.ok)) { + closeDialog(); + } + + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Confirm delete group + + + Are you sure you want to delete this group? + Deleting this group will also delete all channels part of this group. + + + + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + ); +} diff --git a/app/components/context-save-toolbar.jsx b/app/components/context-save-toolbar.jsx new file mode 100644 index 0000000..a6e474f --- /dev/null +++ b/app/components/context-save-toolbar.jsx @@ -0,0 +1,42 @@ +import { Flex, Text, Button, Box } from '@radix-ui/themes'; +import { motion } from 'framer-motion'; +import { useHydrated } from "remix-utils/use-hydrated"; + +export default function ContextSaveToolbar(props) { + + const { hasChanges, save, discard } = props; + + const isHydrated = useHydrated(); + + if (!isHydrated) { + return null; + } + + return ( + + + + ); +} diff --git a/app/components/create-channel-dialog.jsx b/app/components/create-channel-dialog.jsx new file mode 100644 index 0000000..645708f --- /dev/null +++ b/app/components/create-channel-dialog.jsx @@ -0,0 +1,95 @@ +import { Text, Flex, Dialog, TextField, Button, Callout } from '@radix-ui/themes'; +import { useFetcher, useLocation } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function CreateChannelDialog(props) { + const { group, community, closeDialog } = props; + + const fetcher = useFetcher(); + const { pathname } = useLocation(); + + const actionData = fetcher.data; + + const [name, setName] = useState(""); + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + + const isCreating = fetcher.state !== "idle"; + + const handleCreateChannel = useCallback(() => { + if (!valid || !community) { + return; + } + + setErrors(null); + + const data = { + __action: "create_channel", + name: name, + groupId: group?.id, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [name, group, valid, community]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + setName(""); + closeDialog(); + }, [pathname]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Create a channel + {!!group && + In {group.name} + } + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + + ); +} diff --git a/app/components/create-community-dialog.jsx b/app/components/create-community-dialog.jsx new file mode 100644 index 0000000..9a163bf --- /dev/null +++ b/app/components/create-community-dialog.jsx @@ -0,0 +1,134 @@ +import { Text, Flex, Dialog, TextField, Button, RadioGroup, Link, Callout } from '@radix-ui/themes'; +import { useSubmit, useActionData, useNavigation } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function CreateCommunityDialog() { + const navigation = useNavigation(); + const actionData = useActionData(); + const submit = useSubmit(); + + const [name, setName] = useState(""); + const [handle, setHandle] = useState(""); + const [type, setType] = useState("public"); + + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + + const handleCreateCommmunity = useCallback(() => { + if (!valid) { + return; + } + + setErrors(null); + + const data = { + __action: "create_community", + name: name, + handle: handle, + private: type == "private", + }; + + submit(data, { method: "post" }); + + }, [name, handle, type, valid]); + + useEffect(() => { + setValid(name.length >= 3 && handle.length >= 3); + }, [name, handle]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Create a community + + This is the start of something awesome. 🎉 + + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + + + + ); +} diff --git a/app/components/create-group-dialog.jsx b/app/components/create-group-dialog.jsx new file mode 100644 index 0000000..889db61 --- /dev/null +++ b/app/components/create-group-dialog.jsx @@ -0,0 +1,96 @@ +import { Text, Flex, Dialog, TextField, Button, Callout } from '@radix-ui/themes'; +import { useFetcher, useLocation } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function CreateGroupDialog(props) { + const { community, closeDialog } = props; + + const fetcher = useFetcher(); + + const { pathname } = useLocation(); + + const actionData = fetcher.data; + + const [name, setName] = useState(""); + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + + const isCreating = fetcher.state !== "idle"; + + const handleCreateGroup = useCallback(() => { + if (!valid || !community) { + return; + } + + setErrors(null); + + const data = { + __action: "create_group", + name: name, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [name, valid, community]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + setName(""); + closeDialog(); + }, [pathname]); + + useEffect(() => { + if (!!(actionData?.id)) { + closeDialog(); + } + + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Create a group + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + + ); +} diff --git a/app/components/create-invite-dialog.jsx b/app/components/create-invite-dialog.jsx new file mode 100644 index 0000000..1c01f55 --- /dev/null +++ b/app/components/create-invite-dialog.jsx @@ -0,0 +1,101 @@ +import { Text, Flex, Dialog, Button, Callout, Checkbox, TextField, IconButton } from '@radix-ui/themes'; +import { useFetcher, useLocation } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon, ClipboardCopyIcon } from '@radix-ui/react-icons'; + +export default function CreateInviteDialog(props) { + const { open, community, webUrl } = props; + + const fetcher = useFetcher(); + const inviteFetcher = useFetcher(); + const code = inviteFetcher.data?.invite; + + const fetcherErrors = fetcher.data?.errors || inviteFetcher.data?.errors; + + // Display + const [name, setName] = useState(""); + const [expiresOnUse, setExpiresOnUse] = useState(false); + + // Validation + const [errors, setErrors] = useState(false); + const isCreating = fetcher.state !== "idle"; + + const toggleExpiresOnUse = useCallback((changed) => { + setExpiresOnUse(changed); + }, []); + + const copyToClipBoard = useCallback(() => { + navigator.clipboard.writeText(`${webUrl}/c/invite?code=${code}`); + }, [code]); + + const handleCreateInvite = useCallback(() => { + const data = { + __action: "create_invite", + communityHandle: community.handle, + expiresOnUse: expiresOnUse, + } + inviteFetcher.submit(data, { method: "post" }); + }, [community.handle, expiresOnUse, inviteFetcher]); + + useEffect(() => { + setErrors(fetcherErrors); + }, [fetcherErrors]); + + useEffect(() => { + if (!open) { + return; + } + handleCreateInvite(); + }, [community.handle, expiresOnUse, open]); + + return ( + + Invite people + Share with friends this link and invite them to your community. + + {!!errors && + + + + + {errors[0].message} + + } + + + { + setName(v.currentTarget.value); + }} + /> + + + + + + + + + + + + Expires on use + + + + + + + + + + + ); +} diff --git a/app/components/edit-channel-dialog.jsx b/app/components/edit-channel-dialog.jsx new file mode 100644 index 0000000..5e84702 --- /dev/null +++ b/app/components/edit-channel-dialog.jsx @@ -0,0 +1,105 @@ +import { Text, Flex, Dialog, TextField, Button, Callout } from '@radix-ui/themes'; +import { useFetcher, useLocation } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function EditChannelDialog(props) { + const { group, channel, community, closeDialog } = props; + + const fetcher = useFetcher(); + const { pathname } = useLocation(); + + const actionData = fetcher.data; + + const [name, setName] = useState(channel?.name ?? ""); + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + + const isCreating = fetcher.state !== "idle"; + + const handleEditChannel = useCallback(() => { + if (!valid || !community) { + return; + } + + setErrors(null); + + const data = { + __action: "edit_channel", + name: name, + groupId: group?.id, + channelId: channel.id, + channelHandle: channel.handle, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [name, group, valid, community]); + + useEffect(() => { + setValid(false); + setName(channel?.name ?? ""); + },[channel]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + if (!!actionData?.errors) { + return + } + setName(""); + closeDialog(); + }, [pathname, actionData]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Edit channel + {!!group && + In {group.name} + } + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + + ); +} diff --git a/app/components/edit-group-dialog.jsx b/app/components/edit-group-dialog.jsx new file mode 100644 index 0000000..c49d88e --- /dev/null +++ b/app/components/edit-group-dialog.jsx @@ -0,0 +1,95 @@ +import { Text, Flex, Dialog, TextField, Button, Callout } from '@radix-ui/themes'; +import { useFetcher } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export default function EditGroupDialog(props) { + const { group, community, closeDialog } = props; + + const fetcher = useFetcher(); + + const actionData = fetcher.data; + + const [name, setName] = useState(group?.name ?? ""); + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + + const isCreating = fetcher.state !== "idle"; + + const handleEdit = useCallback(() => { + if (!valid || !community) { + return; + } + + setErrors(null); + + const data = { + __action: "edit_group", + name: name, + groupId: group?.id, + communityHandle: community.handle, + }; + + fetcher.submit(data, { method: "post" }); + + }, [name, valid, community]); + + useEffect(() => { + setValid(false); + setName(group?.name ?? ""); + }, [group]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + if (!!(actionData?.id)) { + closeDialog(); + } + + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + Edit group + + {!!errors && + + + + + {errors[0].message} + + } + + + + + + + + + + ); +} diff --git a/app/components/feed-helpers.js b/app/components/feed-helpers.js new file mode 100644 index 0000000..c54e574 --- /dev/null +++ b/app/components/feed-helpers.js @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +export const useStateWithCallbackLazy = initialValue => { + const callbackRef = useRef(null); + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + if (callbackRef.current) { + callbackRef.current(value); + + callbackRef.current = null; + } + }, [value]); + + const setValueWithCallback = useCallback( + (newValue, callback) => { + callbackRef.current = callback; + + return setValue(newValue); + }, + [], + ); + + return [value, setValueWithCallback]; +}; + +export function useIsVisible(ref) { + const [isIntersecting, setIntersecting] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => + setIntersecting(entry.isIntersecting) + ); + + observer.observe(ref.current); + + return () => { + observer.disconnect(); + }; + }, [ref]); + + return isIntersecting; +} diff --git a/app/components/helpers.js b/app/components/helpers.js new file mode 100644 index 0000000..3c7c8bc --- /dev/null +++ b/app/components/helpers.js @@ -0,0 +1,16 @@ +export function friendlyName(name, handle) { + if (!!name && name.length > 0) { + return name; + } + return handle ?? "Ghost"; +} + +export function avatarFallback(name) { + if (name.length == 0) { + return ""; + } + return String.fromCodePoint(name.codePointAt(0)).toUpperCase(); +} + + +export const wkGhostButton = "rt-reset rt-BaseButton rt-Button rt-r-size-2 rt-variant-ghost"; diff --git a/app/components/styles.channel-feed.css b/app/components/styles.channel-feed.css new file mode 100644 index 0000000..0159bb2 --- /dev/null +++ b/app/components/styles.channel-feed.css @@ -0,0 +1,25 @@ +.wk-channel-feed { + position: relative; + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + height: calc(100% - 107px); + box-sizing: border-box; + scrollbar-width: thin; + transition: all 250ms ease; +} + +.wk-channel-message:first-of-type { + margin-top: var(--space-4); +} + +.wk-blur-box { + position: sticky; + bottom: 0px; + left: 0px; + right: 0px; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} diff --git a/app/components/styles.channel-header.css b/app/components/styles.channel-header.css new file mode 100644 index 0000000..aa54197 --- /dev/null +++ b/app/components/styles.channel-header.css @@ -0,0 +1,56 @@ +.wk-channel-header { + -webkit-app-region: drag; + position: static; + top: 0px; + left: 0px; + right: 0px; + border-bottom: 1px solid var(--gray-a3); + padding: var(--space-4); + display: flex; + align-items: center; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + z-index: 1000; +} + +.wk-channel-header > * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; +} + +.wk-searchbar { + -webkit-app-region: no-drag; + -webkit-user-select: auto; + -moz-user-select: auto; + -ms-user-select: auto; + user-select: auto; + cursor: default; +} + +.wk-searchbar { + transition: max-width 0.1s ease-out; + width: 100%; + max-width: 200px; +} + +.wk-searchbar:focus-within { + transition: max-width 0.1s ease-out; + width: 100%; + max-width: 300px; +} + +.wk-searchbar > * { + -webkit-app-region: no-drag; + -webkit-user-select: auto; + -moz-user-select: auto; + -ms-user-select: auto; + user-select: auto; +} diff --git a/app/components/styles.channel-message-skeleton.css b/app/components/styles.channel-message-skeleton.css new file mode 100644 index 0000000..5c1ce54 --- /dev/null +++ b/app/components/styles.channel-message-skeleton.css @@ -0,0 +1,7 @@ +.wk-skeleton span { + background-color: var(--gray-4); +} + +.wk-skeleton:hover span { + background-color: var(--gray-5); +} diff --git a/app/components/styles.channel-message.css b/app/components/styles.channel-message.css new file mode 100644 index 0000000..94a19d1 --- /dev/null +++ b/app/components/styles.channel-message.css @@ -0,0 +1,190 @@ +.wk-channel-message { + margin-left: var(--space-4); + margin-right: var(--space-4); + position: relative; +} + +.wk-channel-message[data-type="active"] { + border-radius: max(var(--radius-3), var(--radius-full)); + background-color: var(--yellow-a3); + box-shadow: 0 0 0 5px var(--yellow-a3); +} + +.wk-channel-message[data-type="normal"] { + border-radius: max(var(--radius-3), var(--radius-full)); + background-color: transparent; + box-shadow: 0 0 0 5px transparent; +} + +.wk-channel-message:hover { + border-radius: max(var(--radius-3), var(--radius-full)); + background-color: var(--gray-a3); + box-shadow: 0 0 0 5px var(--gray-a3); +} + +.wk-channel-message[data-type="active"]:hover { + border-radius: max(var(--radius-3), var(--radius-full)); + background-color: var(--yellow-a4); + box-shadow: 0 0 0 5px var(--yellow-a4); +} + +.wk-channel-message:hover .wk-parent-header { + background-color: transparent; + box-shadow: 0 0 0 5px transparent; +} + +.wk-channel-message:hover .wk-parent-reply { + background-color: transparent; + box-shadow: 0 0 0 5px transparent; +} + +.wk-reaction-bar { + position: absolute; + display: none; +} + +.wk-emojis { + z-index: 100; + border: 1px solid var(--gray-a3); + background-color: var(--mauve-a3); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + width: 70%; +} + +.wk-channel-message:hover .wk-reaction-bar { + display: flex; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border-radius: var(--radius-3); + background-color: var(--gray-a6); + position: absolute; + right: 2px; + top: -10px; + padding: var(--space-2); + z-index: 3; +} + +.wk-repy-border { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 1rem; + display: grid; + width: 53px; +} + +.wk-reply-inner { + margin-left: -7px; + grid-column-start: 2; + height: 14px; + width: 32px; + border-left-width: 2px; + border-top-width: 2px; + border-top-left-radius: 0.5rem; + border-top-color: var(--gray-a6); + border-left-color: var(--gray-a6); + border-top-style: solid; + border-left-style: solid; +} + +.wk-parent-header { + border-top-right-radius: max(var(--radius-3), var(--radius-full)); + border-top-left-radius: max(var(--radius-3), var(--radius-full)); + margin-top: -5px; +} + +.wk-parent-reply { + border-bottom-right-radius: max(var(--radius-3), var(--radius-full)); + border-bottom-left-radius: max(var(--radius-3), var(--radius-full)); +} + +.wk-parent-reply-text { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.wk-message-img { + border-radius: var(--radius-3); + overflow: hidden; +} + +.wk-message-video { + border-radius: var(--radius-3); + overflow: hidden; +} + +.wk-message-video video { + object-fit: cover !important; +} + +.wk-dialog-overlay { + background-color: var(--black-a9); + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px);} + +.wk-dialog-content { + background-color: transparent; + background: transparent; + border-radius: 6px; + box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + max-width: 80vw; + max-height: 80vh; + padding: 25px; + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + display: flex; + flex-direction: column; + padding: var(--space-2); +} + +.wk-dialog-content:focus { + outline: none; +} + +.wk-dialog-image-detail { + object-fit: contain !important; + height: 100%; +} + +.wk-markdown { + unicode-bidi: isolate; +} + +.wk-markdown p { + unicode-bidi: isolate; + display: block; + margin-block-start: 0em; + margin-block-end: 0em; + margin-inline-start: 0px; + margin-inline-end: 0px; +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -45%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + } diff --git a/app/components/styles.channel-replybox.css b/app/components/styles.channel-replybox.css new file mode 100644 index 0000000..559102a --- /dev/null +++ b/app/components/styles.channel-replybox.css @@ -0,0 +1,76 @@ +.wk-reply-container { + padding-left: var(--space-4); + padding-right: var(--space-4); +} +.wk-channel-replybox { + padding-bottom: var(--space-4); + display: flex; + gap: var(--space-1); + flex-direction: column; + position: relative; +} + +.wk-channel-replybox div { + border: 1px solid var(--gray-a4); +} + +.wk-channel-replybox textarea { + background-color: var(--gray-3); + padding-right: 35px; + padding-left: 35px; +} + +.wk-reply-to { + position: absolute; + top: -33px; + left: 0px; + right: 0px; + border-radius: var(--radius-6); + padding: var(--space-1); + padding-left: var(--space-2); + padding-right: var(--space-2); + border: 1px solid var(--gray-a3); + background-color: var(--yellow-6); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + gap: var(--space-1); +} + +.wk-plus-button { + background-color: var(--gray-a7); + position: absolute; + left: 10px; + top: calc(50% - 15px); + z-index: 1; +} + +.wk-reply-box-inner { + min-height: unset; + border-radius: 17px; +} + +.wk-reply-image-container { + position: relative; + border-radius: var(--radius-3); + overflow: hidden; +} + +.wk-reply-image-preview { + border-radius: var(--radius-3); + overflow: hidden; + width: initial !important; +} + +.wk-files-row { + max-width: calc(100vw - 647px); + overflow-x: auto; +} + +.wk-reply-image-remove { + overflow: hidden; + position: absolute; + right: 0px; + top: 0px; +} diff --git a/app/components/styles.channel-users.css b/app/components/styles.channel-users.css new file mode 100644 index 0000000..3fa5ef8 --- /dev/null +++ b/app/components/styles.channel-users.css @@ -0,0 +1,21 @@ +.wk-channel-users { + width: 250px; + background-color: var(--black-a2); + display: flex; + flex-direction: column; + gap: var(--space-2); + border-left: 1px solid var(--gray-3); + padding: var(--space-4); + overflow-y: auto; + scrollbar-width: none; +} + +.wk-channel-users::-webkit-scrollbar { + width: 0px; +} + +.wk-channel-user-button { + border-radius: max(var(--radius-3), var(--radius-full)); + cursor: pointer; + -webkit-app-region: no-drag; +} diff --git a/app/components/styles.community-channel.css b/app/components/styles.community-channel.css new file mode 100644 index 0000000..43ac8c9 --- /dev/null +++ b/app/components/styles.community-channel.css @@ -0,0 +1,15 @@ +.wk-community-main { + background-color: var(--gray-2); + display: flex; + flex-direction: row; + height: 100vh; + width: 100%; +} + +.wk-subtracting-header * .wk-community-main { + height: calc(100vh - var(--community-banner-height)) ; +} + +.wk-channel-main { + width: calc(100vw - 618px); +} diff --git a/app/components/styles.community-settings-panel.css b/app/components/styles.community-settings-panel.css new file mode 100644 index 0000000..d7fd9f8 --- /dev/null +++ b/app/components/styles.community-settings-panel.css @@ -0,0 +1,60 @@ +.wk-communities-settings-panel { + width: 250px; + height: 100vh; + overflow-y: auto; + display: flex; + overflow-y: auto; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-0); + background-color: var(--black-a2); + border-right: 1px solid var(--gray-a3); +} + +.wk-subtracting-header * .wk-communities-settings-panel { + height: calc(100vh - var(--community-banner-height)); +} + +.wk-communities-settings-panel * { + -webkit-app-region: no-drag; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; +} + +.wk-communities-settings-panel > span { + margin-left: var(--space-4); + margin-right: var(--space-4); + justify-content: flex-start; +} + +.wk-communities-settings-panel > a { + margin-left: var(--space-2); + margin-right: var(--space-2); + justify-content: flex-start; +} + +.wk-community-settings-description { + position: relative; + -webkit-app-region: drag; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-bottom: 1px solid var(--gray-a3); + padding: 0; +} + +.wk-community-settings-description-button { + -webkit-app-region: drag; + padding: var(--space-4); + width: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +.wk-community-settings-description-button:hover { + background-color: var(--gray-a3); +} diff --git a/app/components/styles.get-started.css b/app/components/styles.get-started.css new file mode 100644 index 0000000..9962508 --- /dev/null +++ b/app/components/styles.get-started.css @@ -0,0 +1,3 @@ +.wk-find-community { + -webkit-app-region: no-drag; +} diff --git a/app/components/styles.login.css b/app/components/styles.login.css new file mode 100644 index 0000000..d044ab1 --- /dev/null +++ b/app/components/styles.login.css @@ -0,0 +1,10 @@ +.wk-login-bg { + -webkit-app-region: drag; + min-height: 100vh; +} + +.wk-login-area { + -webkit-app-region: no-drag; + max-width: 450px; + width: 100%; +} diff --git a/app/components/styles.signup.css b/app/components/styles.signup.css new file mode 100644 index 0000000..107ed0e --- /dev/null +++ b/app/components/styles.signup.css @@ -0,0 +1,10 @@ +.wk-signup-bg { + -webkit-app-region: drag; + min-height: 100vh; +} + +.wk-signup-area { + -webkit-app-region: no-drag; + max-width: 450px; + width: 100%; +} diff --git a/app/components/styles.twemoji.css b/app/components/styles.twemoji.css new file mode 100644 index 0000000..45ecc35 --- /dev/null +++ b/app/components/styles.twemoji.css @@ -0,0 +1,3 @@ +.emoji { + display: inline-block; +} diff --git a/app/components/styles.user-menu.css b/app/components/styles.user-menu.css new file mode 100644 index 0000000..ec9c9bf --- /dev/null +++ b/app/components/styles.user-menu.css @@ -0,0 +1,208 @@ +.wk-community-button { + position: relative; +} + +.wk-community-active { + position: absolute; + top: 50%; + left: -13px; + transform: translate(-50%, -50%); + height: 30px; + width: 5px; + border-radius: 0px 4px 4px 0px; + background-color: var(--color-text-on-light); +} + +.wk-community-button:hover .wk-community-hovered { + position: absolute; + top: 50%; + left: -13px; + transform: translate(-50%, -50%); + height: 12px; + width: 5px; + border-radius: 0px 4px 4px 0px; + background-color: var(--color-text-on-light); +} + +.wk-channel-users-unread { + position: absolute; + top: 50%; + left: -13px; + transform: translate(-50%, -50%); + height: 6px; + width: 5px; + border-radius: 0px 4px 4px 0px; + background-color: var(--color-text-on-light); +} + +.wk-community-nav { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + display: flex; + flex-direction: row; + gap: 0px; + margin: 0; + margin-block-end: 0; + margin-block-start: 0; + height: 100vh; + background-color: var(--gray-2); +} + +.wk-subtracting-header * .wk-community-nav { + height: calc(100vh - var(--community-banner-height)); +} + +.wk-gear-toggle-parent .wk-gear-toggle { + visibility: hidden; +} + +.wk-gear-toggle-parent:hover .wk-gear-toggle { + visibility: visible; +} + +.wk-community-nav a { + text-decoration: none; +} + +.wk-communities-list { + -webkit-app-region: drag; + width: 80px; + overflow-y: auto; + display: flex; + align-items: center; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-8); + padding-bottom: var(--space-4); + border-right: 1px solid var(--gray-a3); + background-color: var(--color-nav-surface); + scrollbar-width: none; +} + +.wk-communities-list::-webkit-scrollbar { + width: 0px; +} + +.wk-communities-list * { + -webkit-app-region: no-drag; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; +} + +.wk-channels-list { + width: 250px; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-0); + background-color: var(--black-a2); + border-right: 1px solid var(--gray-a3); + position: relative; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: all 250ms ease; +} + +.wk-channels-list:hover { + scrollbar-color: rgb(102, 102, 102) rgb(212, 212, 212); +} + +.wk-subtracting-header * .wk-channels-list { + height: calc(100vh - var(--community-banner-height)); +} + +.wk-channels-list * { + -webkit-app-region: no-drag; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; +} + +.wk-community-description { + position: sticky; + top: 0px; + left: 0px; + right: 0px; + -webkit-app-region: drag; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-bottom: 1px solid var(--gray-a3); + padding: 0; + background-color: var(--black-a1); + z-index: 100; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.wk-community-description-button { + -webkit-app-region: drag; + padding: var(--space-4); + width: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +.wk-community-description-button:hover { + background-color: var(--gray-a3); +} + +.wk-user-connection { + border-top: 1px solid var(--gray-a3); + position: sticky; + bottom: 0; + right: 0; + left: 0; + /* background-color: var(--color-nav-surface); */ + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.wk-user-button { + padding: var(--space-2); + width: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +.wk-user-button:hover { + background-color: var(--gray-a3); +} + +.wk-subtle-button { + background-color: var(--gray-a4); +} + +.wk-subtle-button:hover { + background-color: var(--gray-a6); +} + +.wk-nav-link-channel { + position: relative; +} + +.wk-nav-link-unread { + position: absolute; + left: -4px; + top: calc(50% - 2px); + width: 3px; + height: 6px; + border-radius: 10px; + background-color: var(--color-text-on-light); +} + +.wk-nav-link-channel:hover { + color: var(--color-text-on-light) !important; +} diff --git a/app/components/twemoji.jsx b/app/components/twemoji.jsx new file mode 100644 index 0000000..d14bb1b --- /dev/null +++ b/app/components/twemoji.jsx @@ -0,0 +1,18 @@ +import React, { memo } from 'react'; +import twemoji from 'twemoji'; + +import "./styles.twemoji.css" + +function Twemoji({ emoji }) { + + let img = twemoji.parse(emoji, { + folder: 'svg', + ext: '.svg' + }); + + img = img.replace(" +} + +export default memo(Twemoji) diff --git a/app/components/useAutosizeTextArea.js b/app/components/useAutosizeTextArea.js new file mode 100644 index 0000000..3b19231 --- /dev/null +++ b/app/components/useAutosizeTextArea.js @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +const useAutosizeTextArea = (textAreaRef, value) => { + useEffect(() => { + if (!!textAreaRef) { + textAreaRef.style.height = "0px"; + const scrollHeight = textAreaRef.scrollHeight; + textAreaRef.style.height = scrollHeight + 2 + "px"; + } + }, [textAreaRef, value]); +}; + +export default useAutosizeTextArea; diff --git a/app/components/user-menu.jsx b/app/components/user-menu.jsx new file mode 100644 index 0000000..288be61 --- /dev/null +++ b/app/components/user-menu.jsx @@ -0,0 +1,105 @@ +import { IconButton, Dialog, Tooltip, ContextMenu, Text } from '@radix-ui/themes'; +import { PlusIcon, MagnifyingGlassIcon, GearIcon } from '@radix-ui/react-icons'; +import { useLoaderData, Link, NavLink, useSubmit } from "@remix-run/react"; +import { useCallback, useState } from 'react'; +import CreateCommunityDialog from '../components/create-community-dialog.jsx'; +import CreateChannelDialog from './create-channel-dialog.jsx'; + +import { avatarFallback } from './helpers.js'; + +import './styles.user-menu.css'; + +export default function UserMenu() { + + const data = useLoaderData(); + const submit = useSubmit(); + + const { handle, me } = data; + + const communities = me?.communities; + + const [community, setCommunity] = useState(null); + + const [createChannelOpen, setCreateChannelOpen] = useState(false); + const [createGroupOpen, setCreateGroupOpen] = useState(false); + + const toggleCreateChannelOpen = useCallback((c) => { + setCommunity(c); + setCreateChannelOpen((v) => !v); + }, []); + + const toggleCreateGroupOpen = useCallback((c) => { + setCommunity(c); + setCreateGroupOpen((v) => !v); + }, []); + + const leaveCommunity = useCallback((community) => { + const data = { + __action: "leave_community", + communityHandle: community.handle, + } + submit(data, { method: "post" }); + }, []); + + return ( + + ); +} diff --git a/app/components/websocket-context.jsx b/app/components/websocket-context.jsx new file mode 100644 index 0000000..d62190c --- /dev/null +++ b/app/components/websocket-context.jsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +const WebsocketContext = createContext({}); + +export default WebsocketContext; diff --git a/app/electron.server.js b/app/electron.server.js new file mode 100644 index 0000000..8790349 --- /dev/null +++ b/app/electron.server.js @@ -0,0 +1,12 @@ +import env from "./environment.server.js"; + +let store = null; + +if (env.electron) { + const Store = require('electron-store'); + if (!store) { + store = new Store(); + } +} + +export default store; diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 0000000..999c0a1 --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..73e0120 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,131 @@ +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import isbot from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 15_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent")) + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/app/environment.server.js b/app/environment.server.js new file mode 100644 index 0000000..e3e106d --- /dev/null +++ b/app/environment.server.js @@ -0,0 +1,9 @@ +export default { + webUrl: process.env.WEB_URL ?? "http://localhost:3000", + readHotUrl: process.env.READ_HOT_URL ?? "http://localhost:3005/v1", + writeHotUrl: process.env.WRITE_HOT_URL ?? "http://localhost:3005/v1", + wsUrl: process.env.WS_URL ?? "ws://localhost:3006/ws", + electron: !!process.env.ELECTRON ? process.env.ELECTRON === "true" : true, + wikidHeader: process.env.X_WICKED_HEADER ?? "8041c9f3768f7bb2e39eab09f9d6b9e12cc6a4d", + gitCommitSha: process.env.RAILWAY_GIT_COMMIT_SHA ?? "1", +} diff --git a/app/global.css b/app/global.css new file mode 100644 index 0000000..6dd2142 --- /dev/null +++ b/app/global.css @@ -0,0 +1,310 @@ +body { + margin: 0; +} + +.radix-themes { + --cursor-button: pointer; + --community-banner-height: 40px; +} + +:where(.radix-themes) { + --color-background: rgba(246, 248, 250, 1.0); + --color-text-on-light: black; + --color-nav-surface: var(--gray-6); + --color-text-group: var(--pink-10); + --color-active-nav: var(--gray-8); +} + +:is(.dark, .dark-theme), +:is(.dark, .dark-theme) :where(.radix-themes:not(.light, .light-theme)) { + --color-background: var(--gray-1); + --color-text-on-light: white; + --color-nav-surface: var(--gray-1); + --color-text-group: var(--pink-10); + --color-active-nav: var(--gray-6); +} + +section::-webkit-scrollbar-track { + background-color: var(--black); +} + +section::-webkit-scrollbar-thumb { + background-color: var(--black); +} + +.wk-align-center { + display: flex; + align-items: center; + justify-content: center; +} + +a.active { + background-color: var(--color-active-nav); + color: var(--color-text-on-light) !important; +} + +.wk-toolbar { + position: absolute; + bottom: var(--space-4); + left: var(--space-4); + right: var(--space-4); + max-width: calc(500px - var(--space-5)); + padding: var(--space-3); + display: flex; + align-items: center; + border-radius: var(--radius-4); + border: 1px solid var(--gray-a3); + background-color: var(--mauve-a3); + z-index: 100; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.wk-heading { + position: sticky; + top: 0px; + left: 0px; + right: 0px; + padding: var(--space-4); + display: flex; + align-items: center; + flex-direction: row; + gap: var(--space-3); + -webkit-app-region: drag; + border-bottom: 1px solid var(--gray-a3); + height: min-content; + background-color: var(--gray-a1); + z-index: 100; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.wk-heading a { + -webkit-app-region: no-drag; +} + +.wk-heading-smol { + position: sticky; + top: 0px; + left: 0px; + right: 0px; + padding: var(--space-3); + display: flex; + -webkit-app-region: drag; + border-bottom: 1px solid var(--gray-a3); + height: min-content; + background-color: var(--gray-a1); + z-index: 100; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); +} + +.wk-whitespace-pre { + white-space: -moz-pre-wrap; + white-space: -pre-wrap; + white-space: -o-pre-wrap; + white-space: pre-wrap; + word-wrap: break-word; + max-width: calc(100vw - 725px); +} + +.wk-form-element { + max-width: 500px; +} + +.wk-table-row { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transform: unset; + background-color: var(--color-panel); +} + +.wk-table-row:hover { + background-color: var(--gray-3); +} + +.wk-buttonable { + cursor: pointer; +} + +.wk-movable { + opacity: 1; + cursor: move; +} + +.wk-button-card:hover { + background-color: var(--gray-a2); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; +} + +.wk-fullwidth { + width: 100%; +} + +.wk-community { + display: flex; + flex-direction: row; + margin: 0; + padding: 0; +} + +.wk-community-join-banner { + background-color: var(--blue-6); + height: var(--community-banner-height); + border-bottom: 1px solid var(--gray-a3); + color: var(--color-text-on-light); + gap: var(--space-4); + -webkit-app-region: drag; +} + +.wk-community-join-banner * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-app-region: no-drag; +} + +.wk-community-new-messages { + width: 100%; + background-color: var(--blue-6); + padding: var(--space-1); + border-bottom: 1px solid var(--gray-a3); + color: var(--color-text-on-light); + gap: var(--space-4); +} + +.wk-main-content { + position: relative; + width: 100%; + overflow-y: auto; + background-color: var(--gray-a2); + height: 100vh; +} + +.wk-subtracting-header * .wk-main-content { + height: calc(100vh - var(--community-banner-height)); +} + +.wk-under-content { + height: 100vh; +} + +.wk-subtracting-header * .wk-under-content { + height: calc(100vh - var(--community-banner-height)); +} + +.wk-ghost-link { + text-decoration: none; + color: unset; +} + +.wk-toast-viewport { + --viewport-padding: 25px; + position: fixed; + bottom: 0; + right: calc(50vw - 150px); + display: flex; + flex-direction: column; + padding: var(--viewport-padding); + gap: 10px; + width: 300px; + max-width: 100vw; + margin: 0; + list-style: none; + z-index: 2147483647; + outline: none; +} + +.wk-toast-root { + border: 1px solid var(--gray-2); + border-radius: 6px; + padding: 15px; + display: grid; + grid-template-areas: 'title action' 'description action'; + grid-template-columns: auto max-content; + column-gap: 15px; + align-items: center; +} + +.wk-toast-root[data-variant='success'] { + color: var(--green-a12); + border: 1px solid var(--green-a8); + background-color: var(--green-a1); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.wk-toast-root[data-state='open'] { + animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.wk-toast-root[data-state='closed'] { + animation: hide 100ms ease-in; +} + +.wk-toast-root[data-swipe='move'] { + transform: translateY(var(--radix-toast-swipe-move-y)); +} + +.wk-toast-root[data-swipe='cancel'] { + transform: translateY(0); + transition: transform 200ms ease-out; +} + +.wk-toast-root[data-swipe='end'] { + animation: swipeOut 100ms ease-out; +} + +.wk-radius-full-size-1 { + border-radius: 24px; + overflow: hidden; +} + +.wk-radius-full-size-2 { + border-radius: 32px; + overflow: hidden; +} + +.wk-radius-full-size-3 { + border-radius: 40px; + overflow: hidden; +} + +@keyframes hide { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes slideIn { + from { + transform: translateY(calc(100% + var(--viewport-padding))); + } + + to { + transform: translateY(0); + } +} + +@keyframes swipeOut { + from { + transform: translateY(var(--radix-toast-swipe-end-y)); + } + + to { + transform: translateY(calc(100% + var(--viewport-padding))); + } +} diff --git a/app/root.jsx b/app/root.jsx new file mode 100644 index 0000000..68b36d4 --- /dev/null +++ b/app/root.jsx @@ -0,0 +1,92 @@ +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData +} from "@remix-run/react"; +import { Theme } from '@radix-ui/themes'; +import { cssBundleHref } from "@remix-run/css-bundle"; +import radixStyles from '@radix-ui/themes/styles.css'; +import global from './global.css'; +import { themePreference, authorize } from "./sessions.server.js"; +import { json } from "@remix-run/node"; +import * as Toast from '@radix-ui/react-toast'; +import env from "./environment.server.js"; +import WebsocketContext from "./components/websocket-context.jsx"; +import useWebSocket from 'react-use-websocket'; +import { useLocation } from "@remix-run/react"; + +export const links = () => [ + { rel: "stylesheet", href: radixStyles }, + { rel: "stylesheet", href: global }, + !!cssBundleHref ? { rel: "stylesheet", href: cssBundleHref } : {}, +]; + +export const loader = async ({ request }) => { + const jwt = await authorize(request); + const theme = await themePreference(request); + + return json({ + theme: theme, + wsUrl: `${env.wsUrl}?token=${encodeURIComponent(jwt)}`, + authorized: !!jwt, + }); +} + +function App() { + const data = useLoaderData(); + + const location = useLocation(); + + const { lastJsonMessage, sendJsonMessage } = useWebSocket(data.wsUrl, { + heartbeat: true, + onOpen: () => { + console.log("Connection established"); + }, + onError: () => { + console.log("Connection has an error"); + }, + onClose: () => { + console.log("Connection closed"); + }, + onMessage: (e) => { + + }, + }, data.authorized); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/app/routes/$.jsx b/app/routes/$.jsx new file mode 100644 index 0000000..cb8df1b --- /dev/null +++ b/app/routes/$.jsx @@ -0,0 +1,8 @@ +import { Text, Flex, Link } from '@radix-ui/themes'; + +export default function NotFound() { + return + 💀 This page doesn't exist 🎃 + Return home + ; +} diff --git a/app/routes/[sitemap.xml].jsx b/app/routes/[sitemap.xml].jsx new file mode 100644 index 0000000..63d8ca5 --- /dev/null +++ b/app/routes/[sitemap.xml].jsx @@ -0,0 +1,65 @@ +import sitemap from "../api/sitemap.server"; + +export const loader = async () => { + + const [data, error] = await sitemap(); + + if (!!error) { + return new Response("System error", { + status: 500, + headers: { + "Content-Type": "application/text", + "xml-version": "1.0", + "encoding": "UTF-8" + } + }); + } + + const content = ` + + + https://www.wikid.app/ + 1.0 + always + + + https://www.wikid.app/signup + 1.0 + monthly + + + https://www.wikid.app/login + 1.0 + monthly + + + https://www.wikid.app/terms + 1.0 + monthly + + ${data.communities.map(community => ` + + https://www.wikid.app/c/${community.handle} + 1.0 + always + + ${community.channels.map(channel => ` + + https://www.wikid.app/c/${community.handle}/${channel.handle} + 1.0 + always + + `)} + `)} + + `; + + return new Response(content, { + status: 200, + headers: { + "Content-Type": "application/xml", + "xml-version": "1.0", + "encoding": "UTF-8" + } + }); +}; diff --git a/app/routes/_index.jsx b/app/routes/_index.jsx new file mode 100644 index 0000000..6b63aa5 --- /dev/null +++ b/app/routes/_index.jsx @@ -0,0 +1,135 @@ +import { Flex, Card, Heading, Text, Button, Dialog, TextField, Strong, Link } from '@radix-ui/themes'; +import { authorize } from "../sessions.server.js"; +import { json, redirect } from "@remix-run/node"; +import CreateCommunityDialog from '../components/create-community-dialog.jsx'; +import { createCommunity } from "../wikid.server.js"; +import { useLoaderData } from "@remix-run/react"; +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; + +import '../components/styles.get-started.css' + +export const meta = () => { + return [ + { + title: "Wikid | join a community" + }, + { + property: "og:title", + content: "Wikid | join a community", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({ request }) => { + const jwt = await authorize(request); + + if (!!jwt) { + return redirect('/c/get-started'); + } + + return json({ me: null, errors: null }) +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "create_community") { + + const name = body.get("name"); + const handle = body.get("handle"); + const _private = body.get("private") == "private"; + + const [_, errors, __] = await createCommunity(name, handle, _private, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect(`/c/${handle}`); + } +} + +export default function Index() { + + const data = useLoaderData(); + + const featuredCommunities = [ + { + id: "wikid", + name: "Wikid Beta Testers 💖", + members: 100 + } + ]; + + return ( + + + + + + Create your community + Start your own community and invite friends to join. + {!!(data?.me) ? + + + : + + } + + + + + Join a community + Search for a community to join. + Featured communities + {featuredCommunities.map((c) => { + return + + + })} + + + + + ); +} diff --git a/app/routes/authorize-success.jsx b/app/routes/authorize-success.jsx new file mode 100644 index 0000000..706d350 --- /dev/null +++ b/app/routes/authorize-success.jsx @@ -0,0 +1,54 @@ +import { Flex, Card, Heading } from '@radix-ui/themes'; +import { json, redirect } from "@remix-run/node"; +import { sessionStorage, USER_SESSION_KEY } from "../sessions.server.js"; +import { useLoaderData } from "@remix-run/react"; +import electron from "../electron.server.js" +import env from "../environment.server.js"; + +export const meta = () => { + return [ + { + title: "Wikid | authorization" + }, + { + property: "og:title", + content: "Wikid | authorization", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({request}) => { + + let jwt = null; + + if (env.electron) { + jwt = electron.get(USER_SESSION_KEY); + } else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + jwt = session.get(USER_SESSION_KEY); + } + + if (!!jwt) { + return json({jwt: jwt}) + } else { + return redirect("/login"); + } +} + +export default function Authorize() { + const data = useLoaderData(); + return ( + + + + Authorization success {data.jwt} + + + + ); +} diff --git a/app/routes/authorize.jsx b/app/routes/authorize.jsx new file mode 100644 index 0000000..fa19ad1 --- /dev/null +++ b/app/routes/authorize.jsx @@ -0,0 +1,49 @@ +import { Flex, Card, Heading } from '@radix-ui/themes'; +import { redirect } from "@remix-run/node"; +import { sessionStorage, USER_SESSION_KEY } from "../sessions.server.js"; + +export const meta = () => { + return [ + { + title: "Wikid | authorization" + }, + { + property: "og:title", + content: "Wikid | authorization", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({request}) => { + const url = new URL(request.url); + const jwt = url.searchParams.get('jwt'); + if (!!jwt) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + session.set(USER_SESSION_KEY, jwt); + return redirect("/authorize-success", { + headers: { "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } else { + return redirect('/login'); + } +} + +export default function Authorize() { + return ( + + + + Authorizing... + + + + ); +} diff --git a/app/routes/c.$handle.$channelHandle.jsx b/app/routes/c.$handle.$channelHandle.jsx new file mode 100644 index 0000000..c1e37c7 --- /dev/null +++ b/app/routes/c.$handle.$channelHandle.jsx @@ -0,0 +1,143 @@ +import { json, redirect } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { channel, selectChannel, joinCommunity, editMessage, reactMessage } from "../wikid.server.js"; +import CommunityChannel from '../components/community-channel.jsx'; +import { authorize } from "../sessions.server.js"; + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + const communityHandle = params.handle; + const channelHandle = params.channelHandle; + + if (!!jwt) { + selectChannel(communityHandle, channelHandle, jwt); + } + + return json({ + communityHandle: communityHandle, + channelHandle: channelHandle, + channel: await channel(communityHandle, channelHandle, 0, jwt) + }); +} + +export async function action({ request }) { + + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "react_to_message") { + + const communityHandle = body.get("communityHandle"); + const messageId = body.get("messageId"); + const reaction = body.get("reaction"); + + const [response, errors] = await reactMessage(communityHandle, messageId, reaction, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + if (!!response?.updated) { + return json({ updated: true }); + } + + } else if (type == "edit_message") { + + if (!jwt) { + throw redirect("/login"); + } + + const communityHandle = body.get("communityHandle"); + const messageId = body.get("messageId"); + const text = body.get("text"); + + const [response, errors] = await editMessage(communityHandle, messageId, text, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + if (!!response?.id) { + return json({ updated: true }); + } + } else if (type == "fetch_more_messages") { + + const communityHandle = body.get("communityHandle"); + const channelHandle = body.get("channelHandle"); + const page = body.get("page"); + + return json({ + channel: await channel(communityHandle, channelHandle, page, jwt), + }); + } else if (type == "join_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await joinCommunity(communityHandle, jwt); + + if (!!errors) { + throw redirect("/join-beta"); + } + + throw redirect(`/c/${communityHandle}`); + } + + return json({}); +} + +export const meta = ({ data }) => { + + if (!!data?.communityHandle) { + return [ + { + title: `Wikid - ${data?.communityHandle} ${!!data?.channelHandle ? `| ${data?.channelHandle }`: "" }`, + }, + { + property: "og:title", + content: `Wikid - ${data?.communityHandle} ${!!data?.channelHandle ? `| ${data?.channelHandle }`: "" }`, + }, + { + name: "description", + content: "Wikid community", + }, + ]; + } else { + return [ + { + title: "Wikid | start a community" + }, + { + property: "og:title", + content: "Wikid | start a community", + }, + { + name: "description", + content: "Start a community on Wikid", + }, + ]; + } +}; + +export default function CommunityChannelRoot() { + const data = useLoaderData(); + + if (!data?.channel) { + return null; + } + + const [ channel ] = data.channel; + + if (!channel) { + return null; + } + + const { community } = channel; + + return ; +} diff --git a/app/routes/c.$handle._index.jsx b/app/routes/c.$handle._index.jsx new file mode 100644 index 0000000..dfd4f49 --- /dev/null +++ b/app/routes/c.$handle._index.jsx @@ -0,0 +1,105 @@ +import { Button, Heading, Text, Dialog, Flex, Link } from '@radix-ui/themes'; +import { json } from "@remix-run/node"; +import { authorize } from "../sessions.server.js"; +import { useLoaderData } from "@remix-run/react"; +import { community } from "../wikid.server.js"; +import CreateCommunityDialog from '../components/create-community-dialog.jsx'; + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + const authorized = !!jwt; + const handle = params.handle; + + const [data, errors, status] = await community(handle, jwt); + + return json({ + handle: handle, + community: data, + errors: errors, + status: status, + authorized: authorized + }); +} + +export const meta = ({ data }) => { + if (!!data.community) { + return [ + { + title: data.community.name, + }, + { + property: "og:title", + content: data.community.name, + }, + { + name: "description", + content: "Wikid community", + }, + ]; + } else { + return [ + { + title: "Wikid | start a community" + }, + { + property: "og:title", + content: "Wikid | start a community", + }, + { + name: "description", + content: "Start a community on Wikid", + }, + ]; + } +}; + +export default function Community() { + const data = useLoaderData(); + + const community = data.community; + + if (!!community) { + return ( + + + + + + + ); + } + + const handle = data.handle; + + if (!!handle && handle.length > 0) { + const friendlyHandle = handle.charAt(0).toUpperCase() + handle.slice(1); + + return ( + + + + Nobody has claimed {friendlyHandle} yet 👀 + It could be yours! 🔥 + {!!data?.authorized ? + + + : + + } + + + + ); + } + + return <> +} diff --git a/app/routes/c.$handle.jsx b/app/routes/c.$handle.jsx new file mode 100644 index 0000000..cf28816 --- /dev/null +++ b/app/routes/c.$handle.jsx @@ -0,0 +1,206 @@ +import { redirect, json } from "@remix-run/node"; +import { Outlet } from "@remix-run/react"; +import { + UNEXPECTED_ERROR_MESSAGE, + me, + community, + leaveCommunity, + createInvite, + joinCommunity, + createChannel, + editChannel, + deleteChannel, + createGroup, + editGroup, + deleteGroup } from "../wikid.server.js"; +import CommunityMenu from "../components/community-menu.jsx"; +import { authorize } from "../sessions.server.js"; +import environment from "../environment.server.js"; + +export const shouldRevalidate = () => { + return true; +}; + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + const handle = params.handle; + + let mr; + let cr; + + if (!!handle && !!jwt) { + const requests = [community(handle, jwt), me(jwt)]; + + const v = await Promise.all(requests); + + const [r1] = v[0]; + + cr = r1; + + const [r2, errors] = v[1]; + + if (!!errors) { + return redirect('/login'); + } + + mr = r2; + } else if (!!jwt) { + const [r, errors] = await me(jwt); + + if (!!errors) { + return redirect('/login'); + } + + mr = r; + } else if (!!handle) { + const [r] = await community(handle, jwt) + + cr = r; + } + + return json({ + webUrl: environment.webUrl, + me: mr, + community: cr, + }); +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "edit_channel") { + + const communityHandle = body.get("communityHandle"); + const channelId = body.get("channelId"); + const channelHandle = body.get("channelHandle"); + const name = body.get("name"); + const groupId = body.get("groupId") == "undefined" ? null : body.get("groupId"); + + const [response, errors, __] = await editChannel(communityHandle, name, groupId, channelId, jwt); + + if (!!response?.handle) { + if (response.handle != channelHandle) { + throw redirect(`/c/${communityHandle}/${response.handle}`); + } else { + return json({ updated: true }); + } + } else if (!!errors) { + return json({ errors: errors }); + } else { + return json({ errors: [{ message: UNEXPECTED_ERROR_MESSAGE }] }); + } + } else if (type == "create_channel") { + + const communityHandle = body.get("communityHandle"); + const name = body.get("name"); + const groupId = body.get("groupId") == "undefined" ? null : body.get("groupId"); + + const [response, errors] = await createChannel(communityHandle, name, groupId, jwt); + + if (!!errors) { + return json(errors); + } + + if (!!(response?.handle)) { + throw redirect(`/c/${communityHandle}/${response.handle}`); + } + } else if (type == "edit_group") { + + const communityHandle = body.get("communityHandle"); + const name = body.get("name"); + const groupId = body.get("groupId"); + + const [data, errors] = await editGroup(communityHandle, groupId, name, jwt); + + if (!!errors) { + return json(errors); + } + + return json(data); + + } else if (type == "create_group") { + + const communityHandle = body.get("communityHandle"); + const name = body.get("name"); + + const [data, errors] = await createGroup(communityHandle, name, jwt); + + if (!!errors) { + return json(errors); + } + + return json(data); + } else if (type == "delete_group") { + + const communityHandle = body.get("communityHandle"); + const groupId = body.get("groupId"); + + const [data, errors] = await deleteGroup(communityHandle, groupId, jwt); + + if (!!errors) { + return json(errors); + } + + if (!!(data?.ok)) { + throw redirect(`/c/${communityHandle}/`); + } + + return json(data); + } else if (type == "delete_channel") { + const communityHandle = body.get("communityHandle"); + const channelId = body.get("channelId"); + + const [data, errors] = await deleteChannel(communityHandle, channelId, jwt); + + if (!!errors) { + return json(errors); + } + + return json(data); + } else if (type == "leave_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await leaveCommunity(communityHandle, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect("/c/get-started"); + } else if (type == "join_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await joinCommunity(communityHandle, jwt); + + if (!!errors) { + throw redirect("/join-beta"); + } + + throw redirect(`/c/${communityHandle}`); + } else if (type == "create_invite") { + const communityHandle = body.get("communityHandle"); + const expiresOnUse = body.get("expiresOnUse") == "true"; + + const [data, errors] = await createInvite(communityHandle, expiresOnUse, jwt); + + return json({ + invite: data?.code, + errors: errors + }); + } + + return json({ errors: [{ message: UNEXPECTED_ERROR_MESSAGE }] }); +} + +export default function CommunityNavigation() { + return ( + <> + + + ); +} diff --git a/app/routes/c._index.jsx b/app/routes/c._index.jsx new file mode 100644 index 0000000..b7cf892 --- /dev/null +++ b/app/routes/c._index.jsx @@ -0,0 +1,28 @@ +import { json } from "@remix-run/node"; + +export const loader = async () => { + return json({}); +} + +export const meta = () => { + return [ + { + title: "Wikid | community" + }, + { + property: "og:title", + content: "Wikid | community", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export default function CommunityIndex() { + return ( + <> + + ); +} diff --git a/app/routes/c.get-started.jsx b/app/routes/c.get-started.jsx new file mode 100644 index 0000000..aca7486 --- /dev/null +++ b/app/routes/c.get-started.jsx @@ -0,0 +1,136 @@ +import { Flex, Card, Heading, Text, Button, Dialog, TextField, Strong, Link } from '@radix-ui/themes'; +import { authorize } from "../sessions.server.js"; +import { json, redirect } from "@remix-run/node"; +import CreateCommunityDialog from '../components/create-community-dialog.jsx'; +import { me, createCommunity } from "../wikid.server.js"; +import { useLoaderData } from "@remix-run/react"; +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; + +import '../components/styles.get-started.css' + +export const meta = () => { + return [ + { + title: "Wikid | join a community" + }, + { + property: "og:title", + content: "Wikid | join a community", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({ request }) => { + const jwt = await authorize(request); + + if (!!jwt) { + const [data, errors] = await me(jwt); + return json({ me: data, errors: errors }); + } + + return json({ me: null, errors: null }) +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "create_community") { + + const name = body.get("name"); + const handle = body.get("handle"); + const _private = body.get("private") == "private"; + + const [_, errors, __] = await createCommunity(name, handle, _private, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect(`/c/${handle}`); + } +} + +export default function GetStarted() { + + const data = useLoaderData(); + + const featuredCommunities = [ + { + id: "wikid", + name: "Wikid Beta Testers 💖", + members: 100 + } + ]; + + return ( + + + + + + Create your community + Start your own community and invite friends to join. + {!!(data?.me) ? + + + : + + } + + + + + Join a community + Search for a community to join. + Featured communities + {featuredCommunities.map((c) => { + return + + + })} + + + + + ); +} diff --git a/app/routes/c.invite.jsx b/app/routes/c.invite.jsx new file mode 100644 index 0000000..9925ea2 --- /dev/null +++ b/app/routes/c.invite.jsx @@ -0,0 +1,124 @@ +import { Flex, Card, Heading, Text, Button, TextField } from '@radix-ui/themes'; +import { authorize } from "../sessions.server.js"; +import { json, redirect } from "@remix-run/node"; +import { joinCommunity, checkInvite } from "../wikid.server.js"; +import { useLoaderData, useSubmit, Link } from "@remix-run/react"; +import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; + +import '../components/styles.get-started.css' +import { useCallback } from 'react'; + +export const meta = () => { + return [ + { + title: "Wikid | you've been invited" + }, + { + property: "og:title", + content: "Wikid | you've been invited", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + + const jwt = await authorize(request); + + const [invite, errors] = await checkInvite(code, jwt); + + return json({ code: code, invite: invite, errors: errors }); +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "accept_invite") { + + const code = body.get("code"); + + if (!jwt) { + throw redirect(`/signup?code=${code}`); + } + + const [_, errors] = await joinCommunity(code, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect(`/c/${handle}`); + } +} + +export default function GetStarted() { + + const data = useLoaderData(); + const invite = data.invite; + const code = data.code; + const submit = useSubmit(); + + const handleAcceptInvite = useCallback(() => { + const data = { + __action: "accept_invite", + code: code, + }; + submit(data, { method: "post" }); + }, [code]); + + const renderInvite = (invite) => { + return + You've been invited! + To join {invite.name}. + + + + ; + } + + const renderErrors = () => { + return + Whoops! + This invite has expired. + + + + ; + } + + return ( + + + + + {!!invite ? renderInvite(invite) : renderErrors()} + + + + ); +} diff --git a/app/routes/c.jsx b/app/routes/c.jsx new file mode 100644 index 0000000..6b3427a --- /dev/null +++ b/app/routes/c.jsx @@ -0,0 +1,166 @@ +import { Flex, Text, Button, Callout } from '@radix-ui/themes'; +import { redirect, json } from "@remix-run/node"; +import { authorize } from "../sessions.server.js"; +import { Outlet, useLoaderData, useSubmit } from "@remix-run/react"; +import { createCommunity, createChannel, community, me, leaveCommunity, joinCommunity, UNEXPECTED_ERROR_MESSAGE } from "../wikid.server.js"; +import UserMenu from '../components/user-menu.jsx'; +import { useCallback } from 'react'; + +export const shouldRevalidate = () => { + return true; +}; + +export function ErrorBoundary() { + return ( + + + There was a problem loading this community. + + + ); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + const handle = params.handle; + + let mr; + let cr; + + if (!!handle && !!jwt) { + const v = await Promise.all([ + community(handle, jwt), + me(jwt) + ]); + + const [r1] = v[0]; + + cr = r1; + + const [r2, errors] = v[1]; + + if (!!errors) { + return redirect('/login'); + } + + mr = r2; + } else if (!!jwt) { + const [r, errors] = await me(jwt); + + if (!!errors) { + return redirect('/login'); + } + + mr = r; + } else if (!!handle) { + const [r] = await community(handle, jwt) + + cr = r; + } + + const settings = request.url.includes("settings"); + + return json({ + handle: handle, + me: mr, + community: cr, + settings: settings, + showJoin: cr?.show_can_join || false, + }); +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "create_community") { + + const name = body.get("name"); + const handle = body.get("handle"); + const _private = body.get("private") == "private"; + + const [response, errors, __] = await createCommunity(name, handle, _private, jwt); + + if (!!response?.handle) { + throw redirect(`/c/${handle}`); + } else if (!!errors) { + return json({ errors: errors }); + } else { + return json({ errors: [{ message: UNEXPECTED_ERROR_MESSAGE }] }); + } + + } else if (type == "create_channel") { + + const communityHandle = body.get("communityHandle"); + const name = body.get("name"); + const groupId = body.get("groupId") == "undefined" ? null : body.get("groupId"); + + const [response, errors] = await createChannel(communityHandle, name, groupId, jwt); + + if (!!errors) { + return json(errors); + } + + if (!!(response?.handle)) { + throw redirect(`/c/${communityHandle}/${response.handle}`); + } + + } else if (type == "leave_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await leaveCommunity(communityHandle, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect("/c/get-started"); + } else if (type == "join_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await joinCommunity(communityHandle, jwt); + + if (!!errors) { + throw redirect("/join-beta"); + } + + throw redirect(`/c/${communityHandle}`); + } + + return json({ errors: [{ message: UNEXPECTED_ERROR_MESSAGE }] }); +} + +export default function CommunityRoot() { + const data = useLoaderData(); + const submit = useSubmit(); + const { showJoin, community, handle } = data; + + const handleJoinCommunity = useCallback(() => { + const data = { + __action: "join_community", + communityHandle: community.handle + } + submit(data, { + method: "post", + }); + }, [community]); + + return ( + + {showJoin && + Want to join? + + } +
+ + +
+
+ ); +} diff --git a/app/routes/check-mail.jsx b/app/routes/check-mail.jsx new file mode 100644 index 0000000..bdbf59c --- /dev/null +++ b/app/routes/check-mail.jsx @@ -0,0 +1,30 @@ +import { Flex, Card, Heading, Text } from '@radix-ui/themes'; + +export const meta = () => { + return [ + { + title: "Wikid | check your mail" + }, + { + property: "og:title", + content: "Wikid | check your mail", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export default function Authorize() { + return ( + + + + Check your mail + We've sent you a magic link to login. + + + + ); +} diff --git a/app/routes/health.jsx b/app/routes/health.jsx new file mode 100644 index 0000000..3771fcb --- /dev/null +++ b/app/routes/health.jsx @@ -0,0 +1,30 @@ +import { Flex, Card, Heading } from '@radix-ui/themes'; + +export const meta = () => { + return [ + { + title: "Wikid | health check" + }, + { + property: "og:title", + content: "Wikid | health check", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export default function HealthCheck() { + return ( + + + + 💖 I'm Healthy 👨🏼‍⚕️ + Woop + + + + ); +} diff --git a/app/routes/join-beta.jsx b/app/routes/join-beta.jsx new file mode 100644 index 0000000..bede6f2 --- /dev/null +++ b/app/routes/join-beta.jsx @@ -0,0 +1,113 @@ +import { Flex, Text, Button, Card, TextField, Heading, Strong, Callout, Link } from '@radix-ui/themes'; +import { useSubmit, useActionData, useNavigation, useLoaderData } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import * as EmailValidator from 'email-validator'; +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { joinBeta } from "../wikid.server.js"; +import { redirect, json } from "@remix-run/node"; +import env from "../environment.server.js"; + +export const meta = () => { + return [ + { + title: "Wikid | join beta" + }, + { + property: "og:title", + content: "Wikid | join beta", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + const email = body.get("email"); + + const [_, errors] = await joinBeta({ input: { email: email } }); + + if (!!errors) { + console.error(errors); + return json(errors); + } + + throw redirect("/joined-beta", {}); +} + +export const loader = async () => { + return json({electron: env.electron}); +} + +export default function JoinBeta() { + + const data = useLoaderData(); + const navigation = useNavigation(); + const actionData = useActionData(); + const submit = useSubmit(); + + const [email, setEmail] = useState(""); + + const [valid, setValid] = useState(false); + + const handleJoinBeta = useCallback(() => { + if (!valid) { + return; + } + const data = { + email: email ?? "", + }; + + submit(data, { method: "post" }); + }, [email, valid]); + + useEffect(() => { + setValid(EmailValidator.validate(email)); + }, [email]); + + return ( + + {data.electron && } + + + + Join the waiting list + + Want to be part of something awesome? + + + Register today to get early access. + + You will gain unique flair awarded to your profile. + {!!actionData?.errors && + + + + + {actionData.errors[0].message} + + } + + Email + + + { + if (event.key === "Enter" && navigation.state != "submitting") { + handleJoinBeta(); + } + }} onChange={(v) => { + setEmail(v.currentTarget.value.toLowerCase()); + }} /> + + + Already a member Login + + + + + ); +} diff --git a/app/routes/joined-beta.jsx b/app/routes/joined-beta.jsx new file mode 100644 index 0000000..5979a22 --- /dev/null +++ b/app/routes/joined-beta.jsx @@ -0,0 +1,32 @@ +import { Flex, Card, Heading, Text, Strong } from '@radix-ui/themes'; + +export const meta = () => { + return [ + { + title: "Wikid | join beta" + }, + { + property: "og:title", + content: "Wikid | join beta", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export default function JoinedBeta() { + return ( + + + + Welcome to Wikid 🎉 + Awesome + You'll get an email when we launch the beta test. + Invite your friends too!! + + + + ); +} diff --git a/app/routes/login.jsx b/app/routes/login.jsx new file mode 100644 index 0000000..780c16c --- /dev/null +++ b/app/routes/login.jsx @@ -0,0 +1,157 @@ +import { Flex, Text, Button, Card, TextField, Heading, Link, Callout, Box } from '@radix-ui/themes'; +import { useSubmit, useActionData, useNavigation, useLoaderData } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import * as EmailValidator from 'email-validator'; +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { signIn } from "../wikid.server.js"; +import { sessionStorage, USER_SESSION_KEY } from "../sessions.server.js"; +import { redirect, json } from "@remix-run/node"; +import electron from "../electron.server.js" +import env from "../environment.server.js"; + +import '../components/styles.login.css'; + +export const meta = () => { + return [ + { + title: "Wikid | login" + }, + { + property: "og:title", + content: "Wikid | login", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + + const body = await request.formData(); + + const input = JSON.parse(body.get("input")) + + const [response, errors] = await signIn({ input: input }); + + if (!!errors) { + console.error(errors); + return json(errors); + } + + if (!response.token) { + console.error("No jwt token returned"); + return json([{ message: "Not allowed" }]); + } + + if (env.electron) { + electron.set(USER_SESSION_KEY, response.token); + throw redirect("/c/get-started"); + } else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + session.set(USER_SESSION_KEY, response.token); + + throw redirect("/c/get-started", { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } +} + +export const loader = async ({request}) => { + const url = new URL(request.url); + const code = url.searchParams.get('code'); + return json({ + code: code, + electron: env.electron, + }); +} + +export default function Login() { + + const data = useLoaderData(); + const code = data.code; + + const navigation = useNavigation(); + const actionData = useActionData(); + const submit = useSubmit(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const [valid, setValid] = useState(true); + + const handleLogin = useCallback(() => { + if (!valid) { + return; + } + + const input = { + email: email, + password: password, + code: code, + } + + const data = { + __action: "__login", + input: JSON.stringify(input), + }; + + submit(data, { method: "post" }); + }, [email, password, valid, code]); + + useEffect(() => { + setValid(EmailValidator.validate(email) && password.length >= 6); + }, [email, password]); + + return ( + + {data.electron && } + + + + Login to Wikid + + {!!actionData?.errors && + + + + + {actionData?.errors[0].message} + + } + + Email + + + { + setEmail(v.currentTarget.value.toLowerCase()); + }} /> + + + Password + + + { + if (event.key === "Enter" && navigation.state != "submitting") { + handleLogin(); + } + }} onChange={(v) => { + setPassword(v.currentTarget.value); + }} /> + + + If you don't have an account signup to Wikid + + + + + ); +} diff --git a/app/routes/logout.jsx b/app/routes/logout.jsx new file mode 100644 index 0000000..84f4979 --- /dev/null +++ b/app/routes/logout.jsx @@ -0,0 +1,25 @@ +import { redirect } from "@remix-run/node"; +import { sessionStorage, USER_SESSION_KEY } from "../sessions.server.js"; +import electron from "../electron.server.js"; +import env from "../environment.server.js"; + +export const loader = async ({request}) => { + if (env.electron) { + electron.delete(USER_SESSION_KEY) + throw redirect("/login"); + } else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + session.unset(USER_SESSION_KEY); + throw redirect("/login", { + headers: { "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }) + } +} + +export default function Logout() { + return <>; +} diff --git a/app/routes/s.$handle.settings._index.jsx b/app/routes/s.$handle.settings._index.jsx new file mode 100644 index 0000000..5237636 --- /dev/null +++ b/app/routes/s.$handle.settings._index.jsx @@ -0,0 +1,34 @@ +import { redirect } from "@remix-run/node"; +import { authorize } from "../sessions.server.js"; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | overview" + }, + { + property: "og:title", + content: "Wikid | community settings | overview", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({request, params}) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + return redirect(`/s/${handle}/settings/overview`); +} + +export default function CommunitySettings() { + return (<>); +} diff --git a/app/routes/s.$handle.settings.danger-zone.jsx b/app/routes/s.$handle.settings.danger-zone.jsx new file mode 100644 index 0000000..dddadd7 --- /dev/null +++ b/app/routes/s.$handle.settings.danger-zone.jsx @@ -0,0 +1,114 @@ +import { Flex, Text, Checkbox, Button } from '@radix-ui/themes'; +import { redirect, json } from "@remix-run/node"; +import { authorize } from "../sessions.server.js"; +import { useSubmit, useNavigation, useActionData, useLoaderData } from "@remix-run/react"; +import { useState, useCallback, useEffect } from "react"; +import { deleteCommunity } from '../wikid.server.js'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | danger zone" + }, + { + property: "og:title", + content: "Wikid | community settings | danger zone", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "delete_community") { + + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await deleteCommunity(communityHandle, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + throw redirect('/c/get-started'); + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + return json({handle: handle}); +} + +export default function CommunitySettingsDangeZone() { + + const data = useLoaderData(); + + const communityHandle = data.handle; + + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + + const [understood, setUnderstood] = useState(false); + + const [errors, setErrors] = useState(false); + + const toggleUnderstood = useCallback(() => { + setUnderstood(v => !v); + }, []); + + const toggleDeleteRole = useCallback(() => { + const data = { + __action: "delete_community", + communityHandle: communityHandle, + }; + + submit(data, { method: "post" }); + }, [communityHandle]); + + const isBusy = navigation.state !== "idle"; + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + + + + Delete community + This is a permanent action. + All community data will be removed. + + + I understand + + +
+ +
+
+
+
+ ); +} diff --git a/app/routes/s.$handle.settings.jsx b/app/routes/s.$handle.settings.jsx new file mode 100644 index 0000000..2e9f0c6 --- /dev/null +++ b/app/routes/s.$handle.settings.jsx @@ -0,0 +1,102 @@ +import { redirect, json } from "@remix-run/node"; +import { Outlet } from "@remix-run/react"; +import { community, me } from "../wikid.server.js"; +import { authorize, sessionStorage } from "../sessions.server.js"; +import CommunitySettingsMenu from "../components/community-settings-menu.jsx"; +import UserMenu from "../components/user-menu.jsx"; +import * as Toast from '@radix-ui/react-toast'; +import { useState, useRef, useEffect } from 'react' +import { useLoaderData } from "@remix-run/react"; + +export const shouldRevalidate = () => { + return true; +}; + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + const message = session.get("message"); + + const handle = params.handle; + + let mr; + let cr; + + const v = await Promise.all([ + community(handle, jwt), + me(jwt) + ]); + + const [r1] = v[0]; + + cr = r1; + + if (cr?.permissions?.manage_community != true) { + return redirect('/login'); + } + + const [r2, errors] = v[1]; + + if (!!errors) { + return redirect('/login'); + } + + mr = r2; + + return json({ + path: request.url, + message: message, + me: mr, + community: cr, + }, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); +} + +export default function CommunityNavigation() { + + const data = useLoaderData(); + const message = data.message; + const timerRef = useRef(0); + const [toastOpen, setToastOpen] = useState(false); + const [toast, setToast] = useState(""); + + useEffect(() => { + if (!message || message.length == 0) { + return; + } + + setToast(message); + setToastOpen(true); + + timerRef.current = window.setTimeout(() => { + setToastOpen(false); + setToast(""); + }, 700); + + }, [data, message]); + + return ( +
+ + + + setToastOpen(o)} data-variant="success"> + + {toast} + + + +
+ ); +} diff --git a/app/routes/s.$handle.settings.overview.jsx b/app/routes/s.$handle.settings.overview.jsx new file mode 100644 index 0000000..8470ce7 --- /dev/null +++ b/app/routes/s.$handle.settings.overview.jsx @@ -0,0 +1,152 @@ +import { Flex, Text, TextField, Callout, Button, Box } from '@radix-ui/themes'; +import { redirect, json } from "@remix-run/node"; +import { authorize, sessionStorage } from "../sessions.server.js"; +import { community, editCommunity } from "../wikid.server.js"; +import { useCallback, useState, useEffect } from 'react'; +import { useLoaderData, useSubmit, useNavigation, useActionData } from "@remix-run/react"; +import { InfoCircledIcon } from '@radix-ui/react-icons'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | overview" + }, + { + property: "og:title", + content: "Wikid | community settings | overview", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "edit_community") { + + const communityHandle = body.get("communityHandle"); + const input = body.get("input"); + + const [_, errors] = await editCommunity(communityHandle, input, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.flash("message", "Saved"); + + throw redirect(`/s/${communityHandle}/settings/overview`, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + const [data, errors] = await community(handle, jwt); + + if (data?.permissions?.manage_community == false) { + return redirect(`/c/${handle}`); + } + + return json({ + community: data, + errors: errors + }); +} + +export default function CommunitySettingsOverview() { + + const data = useLoaderData(); + const community = data.community; + + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + + const [name, setName] = useState(community.name); + const [errors, setErrors] = useState(false); + const isBusy = navigation.state !== "idle"; + + const toggleSave = useCallback(() => { + if (!community) { + return; + } + + const input = { + name: name, + }; + + const communityHandle = community.handle; + + const data = { + __action: "edit_community", + input: JSON.stringify(input), + communityHandle: communityHandle, + }; + + submit(data, { method: "post" }); + }, [community, name]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( + + + + {!!errors && + + + + + {errors[0].message} + + } + Make changes to your community. + + + + + + + ); +} diff --git a/app/routes/s.$handle.settings.roles.$roleId.jsx b/app/routes/s.$handle.settings.roles.$roleId.jsx new file mode 100644 index 0000000..a09390f --- /dev/null +++ b/app/routes/s.$handle.settings.roles.$roleId.jsx @@ -0,0 +1,543 @@ +import { Flex, Text, Card, IconButton, Tabs, Callout, Box, TextField, Checkbox, Strong, Table, Button } from '@radix-ui/themes'; +import { json, redirect } from "@remix-run/node"; +import { useState, useCallback, useEffect } from "react"; +import { useLoaderData, Link, useSubmit, useNavigation, useActionData } from "@remix-run/react"; +import { authorize, sessionStorage } from "../sessions.server.js"; +import { community, communityUsers, communityRole, editRole, deleteRole } from '../wikid.server.js'; +import ContextSaveToolbar from '../components/context-save-toolbar.jsx'; +import { CheckIcon, ChevronLeftIcon, InfoCircledIcon } from '@radix-ui/react-icons'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | roles | edit" + }, + { + property: "og:title", + content: "Wikid | community settings | roles | edit", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "edit_role") { + + const communityHandle = body.get("communityHandle"); + + const input = body.get("input"); + + const [response, errors] = await editRole(communityHandle, input, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + if (!!response?.id) { + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.flash("message", "Saved"); + + throw redirect(`/s/${communityHandle}/settings/roles`, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, {}), + }, + }); + } + } else if (action == "delete_role") { + const communityHandle = body.get("communityHandle"); + + const input = body.get("input"); + + await deleteRole(communityHandle, input, jwt); + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.flash("message", "Deleted"); + + throw redirect(`/s/${communityHandle}/settings/roles/`, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, {}), + }, + }); + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + const roleId = params.roleId; + + const v = await Promise.all([ + community(handle, jwt), + communityUsers(handle, jwt), + communityRole(handle, roleId, jwt) + ]); + + const [communityData, communityErrors] = v[0]; + const [usersData, usersErrors] = v[1]; + const [roleData, roleErrors] = v[2]; + + if (communityData?.permissions?.manage_community == false) { + return redirect(`/c/${handle}`); + } + + return json({ + handle: handle, + community: communityData, + roleId: roleId, + users: usersData, + role: roleData, + errors: communityErrors ?? usersErrors ?? roleErrors, + }); +} + +export default function EditCommunitySettingsRoles() { + const data = useLoaderData(); + + const roleId = data.roleId; + const community = data.community; + + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + + // Display + const [name, setName] = useState(data.role?.name); + const [color, setColor] = useState(data.role?.color ?? "gray"); + const [showOnlineDifferently, setShowOnlineDifferently] = useState(data.role?.show_online_differently == true); + const [allowEveryone, setAllowEveryone] = useState(false); + + // Permissions + const [viewChannels, setViewChannels] = useState(data.role?.view_channels == true); + const [mannageChannels, setManageChannels] = useState(data.role?.manage_channels == true); + const [mannageCommunity, setManageCommunity] = useState(data.role?.manage_community == true); + const [createInvite, setCreateInvite] = useState(data.role?.create_invite == true); + const [kickMembers, setKickMembers] = useState(data.role?.kick_members == true); + const [banMembers, setBanMembers] = useState(data.role?.ban_members == true); + const [sendMessages, setSendMessages] = useState(data.role?.send_messages == true); + const [attachMedia, setAttachMedia] = useState(data.role?.attach_media == true); + + // Members + const [users, setUsers] = useState(data.users); + const [usersInRole, setUsersInRole] = useState(data.role?.members ?? []); + + // Searching + + const [searchMember, setSearchMember] = useState(""); + + // Validation + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + const isBusy = navigation.state !== "idle"; + + // Animations + const [hasChanges, setHasChanges] = useState(false); + + const colors = ["tomato", "red", "ruby", "crimson", "pink", "plum", + "purple", "violet", "iris", "indigo", "blue", "cyan", + "teal", "jade", "green", "grass", "brown", "orange", + "sky", "mint", "lime", "yellow", "amber", "gold", + "bronze", "gray"]; + + const handleEditRole = useCallback(() => { + if (!valid || !community) { + return; + } + + const communityHandle = community.handle; + + setErrors(null); + + const input = { + id: roleId, + name: name, + show_online_differently: showOnlineDifferently, + color: color, + view_channels: viewChannels, + manage_channels: mannageChannels, + manage_community: mannageCommunity, + create_invite: createInvite, + kick_members: kickMembers, + ban_members: banMembers, + send_messages: sendMessages, + attach_media: attachMedia, + members: usersInRole, + }; + + const data = { + __action: "edit_role", + input: JSON.stringify(input), + communityHandle: communityHandle, + roleId: roleId, + }; + + submit(data, { method: "post" }); + + }, [community, roleId, name, showOnlineDifferently, viewChannels, mannageChannels, mannageCommunity, createInvite, kickMembers, banMembers, sendMessages, attachMedia, color, valid, community, usersInRole]); + + const toggleUserInRole = useCallback((userId) => { + setHasChanges(true); + const index = usersInRole.indexOf(userId); + if (index === -1) { + setUsersInRole([...usersInRole, userId]); + } else { + const cpy = [...usersInRole]; + cpy.splice(index, 1) + setUsersInRole(cpy); + } + }, [usersInRole]); + + const [understood, setUnderstood] = useState(false); + + const toggleUnderstood = useCallback(() => { + setUnderstood(v => !v); + }, []); + + const toggleDeleteRole = useCallback(() => { + if (!community) { + return; + } + + const input = { + id: roleId + }; + + const communityHandle = community.handle; + + const data = { + __action: "delete_role", + input: JSON.stringify(input), + communityHandle: communityHandle, + roleId: roleId, + }; + + submit(data, { method: "post" }); + }, [community, roleId]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + useEffect(() => { + if (searchMember.length > 0) { + setHasChanges(true); + } + + if (searchMember.length == 0) { + setUsers(data.users); + } else { + setUsers(data.users.filter((u) => (u.handle.includes(searchMember) || u.name.includes(searchMember)))); + } + }, [searchMember, data.users]); + + return ( +
+ + + + {!!errors && + + + + + {errors[0].message} + + } + + + Display + Permissions + Members + Danger zone + + + + + + Role name + + { + setHasChanges(true); + setName(v.currentTarget.value); + }} + /> + + + + Role color + + + {colors.map((o) => { + setColor(o); + setHasChanges(true); + }} radius='full' size="1" key={o} color={o}> + {o == color ? : ''} + )} + + + + + { + setHasChanges(true); + setShowOnlineDifferently(o); + }} /> Display role members differently from online members. + + + + + { + setHasChanges(true); + setAllowEveryone(o); + }} /> Allow anyone to @mention this role. + + + + + + + + + { + setHasChanges(true); + setViewChannels(!viewChannels); + }}> + + + + View channels + Allows members to view channels (excluding private channels). + + { + setHasChanges(true); + setViewChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageChannels(!mannageChannels); + }}> + + + + Manage channels + Allows members to modify channels of the community. + + { + setHasChanges(true); + setManageChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageCommunity(!mannageCommunity); + }}> + + + + Manage community + Allows members to manage the settings of the comunity. + + { + setHasChanges(true); + setManageCommunity(o); + }} /> + + + + { + setHasChanges(true); + setCreateInvite(!createInvite); + }}> + + + + Create invite + Allows members to create invites to the community. + + { + setHasChanges(true); + setCreateInvite(o); + }} /> + + + + { + setHasChanges(true); + setKickMembers(!kickMembers); + }}> + + + + Kick members + Allows members to kick people from the community. + + { + setHasChanges(true); + setKickMembers(o); + }} /> + + + + { + setHasChanges(true); + setBanMembers(!banMembers); + }}> + + + + Ban members + Allows members ban people from the community. + + { + setHasChanges(true); + setBanMembers(o); + }} /> + + + + { + setHasChanges(true); + setSendMessages(!sendMessages); + }}> + + + + Send messages + Allows members send messages in text channels. + + { + setHasChanges(true); + setSendMessages(o); + }} /> + + + + { + setHasChanges(true); + setAttachMedia(!attachMedia); + }} mb="5"> + + + + Attach media + Allows members attach files, photos and other media in messages. + + { + setHasChanges(true); + setAttachMedia(o); + }} /> + + + + + + + + + + Member name + + { + setSearchMember(v.currentTarget.value); + }} + /> + + + {users.length > 0 && + + + Name + Handle + + } + + {users.length > 0 ? users.map((u) => toggleUserInRole(u.id)} key={`utr-${u.id}`} className='wk-table-row wk-buttonable'> + toggleUserInRole(u.id)} className='wk-buttonable' /> + {u.name} + @{u.handle} + ) : + + No matches for {searchMember} + } + + + + + + + + + Delete role + This is a permanent action. + All members of this role will be affected. + + + I understand + + +
+ +
+
+
+
+
+
+ {hasChanges && { + window.location.href = `/s/${community.handle}/settings/roles`; + }} + save={() => { + if (!isBusy) { + handleEditRole(); + } + }} + />} +
+ ); +} diff --git a/app/routes/s.$handle.settings.roles._index.jsx b/app/routes/s.$handle.settings.roles._index.jsx new file mode 100644 index 0000000..bfcf19a --- /dev/null +++ b/app/routes/s.$handle.settings.roles._index.jsx @@ -0,0 +1,256 @@ +import { Flex, Text, Card, Table, Badge, IconButton } from '@radix-ui/themes'; +import { json, redirect } from "@remix-run/node"; +import { Pencil1Icon, ChevronRightIcon } from '@radix-ui/react-icons'; +import { useLoaderData, Link, useFetcher } from "@remix-run/react"; +import { authorize } from "../sessions.server.js"; +import { community, communityRoles, editRolesPriority } from '../wikid.server.js'; +import { wkGhostButton } from '../components/helpers.js'; +import { DndProvider, useDrag, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useCallback, useState, useRef, useEffect } from 'react' +import update from 'immutability-helper'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | roles" + }, + { + property: "og:title", + content: "Wikid | community settings | roles", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "edit_priorities") { + + const communityHandle = body.get("communityHandle"); + + const roleIds = JSON.parse(body.get("roleIds")); + + const [_, errors] = await editRolesPriority(communityHandle, roleIds, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + const v = await Promise.all([ + community(handle, jwt), + communityRoles(handle, jwt) + ]); + + const [communityData, errors] = v[0]; + const [rolesData] = v[1]; + + if (communityData?.permissions?.manage_community == false) { + return redirect(`/c/${handle}`); + } + + return json( + { + handle: handle, + community: communityData, + roles: rolesData, + errors: errors, + } + ); +} + +function RoleRow({ role, moveRole, id, index, community }) { + const { name, members, color } = role; + + const ref = useRef(null); + + const [{ handlerId }, drop] = useDrop({ + accept: "role", + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + } + }, + hover(item, monitor) { + if (!ref.current) { + return + } + const dragIndex = item.index + const hoverIndex = index + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return + } + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect() + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2 + // Determine mouse position + const clientOffset = monitor.getClientOffset() + // Get pixels to the top + const hoverClientY = clientOffset.y - hoverBoundingRect.top + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return + } + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return + } + // Time to actually perform the action + moveRole(dragIndex, hoverIndex) + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + item.index = hoverIndex + }, + }); + + const [{ }, drag] = useDrag({ + type: "role", + item: () => { + return { id, index, role }; + }, + + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + drag(drop(ref)); + + return ( + + {name} + {members} + + + + + + + + + ); +} + +export default function CommunitySettingsRoles() { + const data = useLoaderData(); + + const community = data.community; + + const rolesFetcher = useFetcher(); + + const [roles, setRoles] = useState(data.roles); + + const moveRole = useCallback((dragIndex, hoverIndex) => { + setRoles((prevRoles) => { + const roles = update(prevRoles, { + $splice: [ + [dragIndex, 1], + [hoverIndex, 0, prevRoles[dragIndex]], + ], + }); + + const ids = roles.map(r => r.id); + + const data = { + __action: "edit_priorities", + communityHandle: community.handle, + roleIds: JSON.stringify(ids), + } + + rolesFetcher.submit(data, {method: "post"}); + + return roles; + }); + + }, [roles, community]); + + return ( + <> + + + + Use roles to assign permissions to members of your community. + + + + + Default Permissions + Applies to @everyone + +
+ + + + + + + Create role + + + {roles.length > 0 ? + + + + + Role + Members + + + + + {roles.map((r, i) => )} + + + + : + + + + You don't have any roles. + + + + + + + Create role + + + + + } + + + + ); +} diff --git a/app/routes/s.$handle.settings.roles.create.jsx b/app/routes/s.$handle.settings.roles.create.jsx new file mode 100644 index 0000000..4638f5f --- /dev/null +++ b/app/routes/s.$handle.settings.roles.create.jsx @@ -0,0 +1,472 @@ +import { Flex, Text, Card, IconButton, Tabs, Callout, Box, TextField, Checkbox, Strong, Table } from '@radix-ui/themes'; +import { json, redirect } from "@remix-run/node"; +import { useState, useCallback, useEffect } from "react"; +import { useLoaderData, Link, useSubmit, useNavigation, useActionData } from "@remix-run/react"; +import { sessionStorage, authorize } from "../sessions.server.js"; +import { community, communityUsers, createRole } from '../wikid.server.js'; +import ContextSaveToolbar from '../components/context-save-toolbar.jsx'; +import { CheckIcon, ChevronLeftIcon, InfoCircledIcon } from '@radix-ui/react-icons'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | roles" + }, + { + property: "og:title", + content: "Wikid | community settings | roles", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "create_role") { + + const communityHandle = body.get("communityHandle"); + const input = body.get("input"); + + const [response, errors] = await createRole(communityHandle, input, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + if (!!response?.id) { + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.flash("message", "Saved"); + + throw redirect(`/s/${communityHandle}/settings/roles`, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + const v = await Promise.all([community(handle, jwt), communityUsers(handle, jwt)]); + + const [communityData, communityErrors] = v[0]; + const [usersData, usersErrors] = v[1]; + + if (communityData?.permissions?.manage_community == false) { + return redirect(`/c/${handle}`); + } + + return json({ + handle: handle, + community: communityData, + users: usersData, + errors: communityErrors ?? usersErrors, + }); +} + +export default function CommunitySettingsRoles() { + const data = useLoaderData(); + + const community = data.community; + + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + + // Display + const [name, setName] = useState(""); + const [color, setColor] = useState("gray"); + const [showOnlineDifferently, setShowOnlineDifferently] = useState(false); + const [allowEveryone, setAllowEveryone] = useState(false); + + // Permissions + const [viewChannels, setViewChannels] = useState(true); + const [mannageChannels, setManageChannels] = useState(false); + const [mannageCommunity, setManageCommunity] = useState(false); + const [createInvite, setCreateInvite] = useState(true); + const [kickMembers, setKickMembers] = useState(false); + const [banMembers, setBanMembers] = useState(false); + const [sendMessages, setSendMessages] = useState(true); + const [attachMedia, setAttachMedia] = useState(true); + + // Members + const [users, setUsers] = useState(data.users); + const [usersInRole, setUsersInRole] = useState([]); + + // Searching + + const [searchMember, setSearchMember] = useState(""); + + // Validation + const [valid, setValid] = useState(false); + const [errors, setErrors] = useState(false); + const isBusy = navigation.state !== "idle"; + + // Animations + const [hasChanges, setHasChanges] = useState(false); + + const colors = ["tomato", "red", "ruby", "crimson", "pink", "plum", + "purple", "violet", "iris", "indigo", "blue", "cyan", + "teal", "jade", "green", "grass", "brown", "orange", + "sky", "mint", "lime", "yellow", "amber", "gold", + "bronze", "gray"]; + + const handleCreateRole = useCallback(() => { + if (!valid || !community) { + return; + } + + const communityHandle = community.handle; + + setErrors(null); + + const input = { + name: name, + show_online_differently: showOnlineDifferently, + color: color, + view_channels: viewChannels, + manage_channels: mannageChannels, + manage_community: mannageCommunity, + create_invite: createInvite, + kick_members: kickMembers, + ban_members: banMembers, + send_messages: sendMessages, + attach_media: attachMedia, + members: usersInRole, + }; + + const data = { + __action: "create_role", + input: JSON.stringify(input), + communityHandle: communityHandle, + }; + + submit(data, { method: "post" }); + + }, [community, name, showOnlineDifferently, viewChannels, mannageChannels, mannageCommunity, createInvite, kickMembers, banMembers, sendMessages, attachMedia, color, valid, community, usersInRole]); + + const toggleUserInRole = useCallback((userId) => { + setHasChanges(true); + const index = usersInRole.indexOf(userId); + if (index === -1) { + setUsersInRole([...usersInRole, userId]); + } else { + const cpy = [...usersInRole]; + cpy.splice(index, 1) + setUsersInRole(cpy); + } + }, [usersInRole]); + + useEffect(() => { + setValid(name.length >= 3); + }, [name]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + useEffect(() => { + if (searchMember.length > 0) { + setHasChanges(true); + } + + if (searchMember.length == 0) { + setUsers(data.users); + } else { + setUsers(data.users.filter((u) => (u.handle.includes(searchMember) || u.name.includes(searchMember)))); + } + }, [searchMember, data.users]); + + return ( +
+ + + + {!!errors && + + + + + {errors[0].message} + + } + + + Display + Permissions + Members + + + + + + Role name + + { + setHasChanges(true); + setName(v.currentTarget.value); + }} + /> + + + + Role color + + + {colors.map((o) => { + setColor(o); + setHasChanges(true); + }} radius='full' size="1" key={o} color={o}> + {o == color ? : ''} + )} + + + + + { + setHasChanges(true); + setShowOnlineDifferently(o); + }} /> Display role members differently from online members. + + + + + { + setHasChanges(true); + setAllowEveryone(o); + }} /> Allow anyone to @mention this role. + + + + + + + + + { + setHasChanges(true); + setViewChannels(!viewChannels); + }}> + + + + View channels + Allows members to view channels (excluding private channels). + + { + setHasChanges(true); + setViewChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageChannels(!mannageChannels); + }}> + + + + Manage channels + Allows members to modify channels of the community. + + { + setHasChanges(true); + setManageChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageCommunity(!mannageCommunity); + }}> + + + + Manage community + Allows members to manage the settings of the comunity. + + { + setHasChanges(true); + setManageCommunity(o); + }} /> + + + + { + setHasChanges(true); + setCreateInvite(!createInvite); + }}> + + + + Create invite + Allows members to create invites to the community. + + { + setHasChanges(true); + setCreateInvite(o); + }} /> + + + + { + setHasChanges(true); + setKickMembers(!kickMembers); + }}> + + + + Kick members + Allows members to kick people from the community. + + { + setHasChanges(true); + setKickMembers(o); + }} /> + + + + { + setHasChanges(true); + setBanMembers(!banMembers); + }}> + + + + Ban members + Allows members ban people from the community. + + { + setHasChanges(true); + setBanMembers(o); + }} /> + + + + { + setHasChanges(true); + setSendMessages(!sendMessages); + }}> + + + + Send messages + Allows members send messages in text channels. + + { + setHasChanges(true); + setSendMessages(o); + }} /> + + + + { + setHasChanges(true); + setAttachMedia(!attachMedia); + }} mb="5"> + + + + Attach media + Allows members attach files, photos and other media in messages. + + { + setHasChanges(true); + setAttachMedia(o); + }} /> + + + + + + + + + + Member name + + { + setSearchMember(v.currentTarget.value); + }} + /> + + + {users.length > 0 && + + + Name + Handle + + } + + {users.length > 0 ? users.map((u) => toggleUserInRole(u.id)} key={u.handle} className='wk-table-row wk-buttonable'> + toggleUserInRole(u.id)} className='wk-buttonable' /> + {u.name} + @{u.handle} + ) : + + No matches for {searchMember} + } + + + + + + + + + + {hasChanges && { + window.location.href = `/s/${community.handle}/settings/roles`; + }} + save={() => { + if (!isBusy) { + handleCreateRole(); + } + }} + />} +
+ ); +} diff --git a/app/routes/s.$handle.settings.roles.default-permissions.jsx b/app/routes/s.$handle.settings.roles.default-permissions.jsx new file mode 100644 index 0000000..357825c --- /dev/null +++ b/app/routes/s.$handle.settings.roles.default-permissions.jsx @@ -0,0 +1,312 @@ +import { Flex, Text, Card, IconButton, Callout, Checkbox } from '@radix-ui/themes'; +import { json, redirect } from "@remix-run/node"; +import { useState, useCallback, useEffect } from "react"; +import { useLoaderData, Link, useSubmit, useNavigation, useActionData } from "@remix-run/react"; +import { authorize, sessionStorage } from "../sessions.server.js"; +import { defaultPermissions, editDefaultPermissions } from '../wikid.server.js'; +import ContextSaveToolbar from '../components/context-save-toolbar.jsx'; +import { ChevronLeftIcon } from '@radix-ui/react-icons'; + +export const meta = () => { + return [ + { + title: "Wikid | community settings | default permissions" + }, + { + property: "og:title", + content: "Wikid | community settings | default permissions", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "edit_default_permissions") { + + const communityHandle = body.get("communityHandle"); + const input = body.get("input"); + + const [_, errors] = await editDefaultPermissions(communityHandle, input, jwt); + + if (!!errors) { + return json({ errors: errors }); + } + + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.flash("message", "Saved"); + + throw redirect(`/s/${communityHandle}/settings/roles`, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } + + return json({}); +} + +export const loader = async ({ request, params }) => { + const jwt = await authorize(request); + + if (!jwt) { + return redirect('/c/get-started'); + } + + const handle = params.handle; + + const [data, errors] = await defaultPermissions(handle, jwt); + + if (!!errors) { + return redirect(`/c/${handle}`); + } + + return json({ + handle: handle, + permissions: data, + errors: errors, + }); +} + +export default function CommunitySettingsDefaultPermissions() { + const data = useLoaderData(); + + const communityHandle = data.handle; + const permissions = data.permissions; + + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + + // Permissions + const [viewChannels, setViewChannels] = useState(permissions.view_channels == true); + const [mannageChannels, setManageChannels] = useState(permissions.manage_channels == true); + const [mannageCommunity, setManageCommunity] = useState(permissions.manage_community == true); + const [createInvite, setCreateInvite] = useState(permissions.create_invite == true); + const [kickMembers, setKickMembers] = useState(permissions.kick_members == true); + const [banMembers, setBanMembers] = useState(permissions.ban_members == true); + const [sendMessages, setSendMessages] = useState(permissions.send_messages == true); + const [attachMedia, setAttachMedia] = useState(permissions.attach_media == true); + + // Validation + const [errors, setErrors] = useState(data.errors); + const isBusy = navigation.state !== "idle"; + + // Animations + const [hasChanges, setHasChanges] = useState(false); + + const handleUpdatePermissions = useCallback(() => { + setErrors(null); + + const input = { + view_channels: viewChannels, + manage_channels: mannageChannels, + manage_community: mannageCommunity, + create_invite: createInvite, + kick_members: kickMembers, + ban_members: banMembers, + send_messages: sendMessages, + attach_media: attachMedia, + }; + + const data = { + __action: "edit_default_permissions", + input: JSON.stringify(input), + communityHandle: communityHandle, + }; + + submit(data, { method: "post" }); + + }, [communityHandle, viewChannels, mannageChannels, mannageCommunity, createInvite, kickMembers, banMembers, sendMessages, attachMedia]); + + useEffect(() => { + setErrors(actionData?.errors); + }, [actionData]); + + return ( +
+ + + + {!!errors && + + + + + {errors[0].message} + + } + These permissions will apply to all members by default. + + { + setHasChanges(true); + setViewChannels(!viewChannels); + }}> + + + + View channels + Allows members to view channels (excluding private channels). + + { + setHasChanges(true); + setViewChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageChannels(!mannageChannels); + }}> + + + + Manage channels + Allows members to modify channels of the community. + + { + setHasChanges(true); + setManageChannels(o); + }} /> + + + + { + setHasChanges(true); + setManageCommunity(!mannageCommunity); + }}> + + + + Manage community + Allows members to manage the settings of the comunity. + + { + setHasChanges(true); + setManageCommunity(o); + }} /> + + + + { + setHasChanges(true); + setCreateInvite(!createInvite); + }}> + + + + Create invite + Allows members to create invites to the community. + + { + setHasChanges(true); + setCreateInvite(o); + }} /> + + + + { + setHasChanges(true); + setKickMembers(!kickMembers); + }}> + + + + Kick members + Allows members to kick people from the community. + + { + setHasChanges(true); + setKickMembers(o); + }} /> + + + + { + setHasChanges(true); + setBanMembers(!banMembers); + }}> + + + + Ban members + Allows members ban people from the community. + + { + setHasChanges(true); + setBanMembers(o); + }} /> + + + + { + setHasChanges(true); + setSendMessages(!sendMessages); + }}> + + + + Send messages + Allows members send messages in text channels. + + { + setHasChanges(true); + setSendMessages(o); + }} /> + + + + { + setHasChanges(true); + setAttachMedia(!attachMedia); + }} mb="5"> + + + + Attach media + Allows members attach files, photos and other media in messages. + + { + setHasChanges(true); + setAttachMedia(o); + }} /> + + + + + + + {hasChanges && { + window.location.href = `/s/${communityHandle}/settings/roles`; + }} + save={() => { + if (!isBusy) { + handleUpdatePermissions(); + } + }} + />} +
+ ); +} diff --git a/app/routes/signup.jsx b/app/routes/signup.jsx new file mode 100644 index 0000000..a8b3507 --- /dev/null +++ b/app/routes/signup.jsx @@ -0,0 +1,177 @@ +import { Flex, Text, Button, Card, TextField, Heading, Link, Checkbox, Callout, Box } from '@radix-ui/themes'; +import { useState, useCallback, useEffect } from "react"; +import { useSubmit, useActionData, useNavigation, useLoaderData } from "@remix-run/react"; +import * as EmailValidator from 'email-validator'; +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { signUp } from "../wikid.server.js"; +import { sessionStorage, USER_SESSION_KEY } from "../sessions.server.js"; +import { redirect, json } from "@remix-run/node"; +import env from "../environment.server.js"; + +import '../components/styles.signup.css'; + +export const meta = () => { + return [ + { + title: "Wikid | sign up" + }, + { + property: "og:title", + content: "Wikid | sign up", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export async function action({ request }) { + const body = await request.formData(); + + const input = JSON.parse(body.get("input")); + + const [response, errors] = await signUp({ input: input }); + + if (!!errors) { + console.error(errors); + if (errors[0].message.includes("beta test")) { + throw redirect("/join-beta"); + } + return json({errors: errors}); + } + + if (!response.token) { + console.error("No jwt token returned"); + return json([{ message: "Not allowed" }]); + } + + if (env.electron) { + electron.set(USER_SESSION_KEY, response.token); + throw redirect("/c/get-started"); +} else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + session.set(USER_SESSION_KEY, response.token); + + throw redirect("/c/get-started", { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } +} + + +export const loader = async ({request}) => { + const url = new URL(request.url); + const code = url.searchParams.get('code'); + return json({ + code: code, + electron: env.electron, + }); +} + +export default function Signup() { + const data = useLoaderData(); + const code = data.code; + const navigation = useNavigation(); + const actionData = useActionData(); + const submit = useSubmit(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [handle, setHandle] = useState(""); + const [dob, setDob] = useState(false); + const [agreed, setAgreed] = useState(false); + + const [valid, setValid] = useState(false); + + const toggleAgreed = useCallback(() => { setAgreed(current => !current); }, []); + const toggleDob = useCallback(() => { setDob(current => !current); }, []); + + const handleSignUp = useCallback(() => { + if (!valid) { + return; + } + + const input = { + email: email, + password: password, + handle: handle, + code: code, + }; + + const data = { + input: JSON.stringify(input) + } + + submit(data, { method: "post" }); + }, [email, password, handle, agreed, valid, code]); + + useEffect(() => { + setValid(EmailValidator.validate(email) && password.length >= 6 && handle.length >= 3 && agreed && dob); + }, [email, password, handle, agreed, dob]); + + return ( + + {data.electron && } + + + + Signup to Wikid + + {!!actionData?.errors && + + + + + {actionData.errors[0].message} + + } + + Email + + + { + setEmail(v.currentTarget.value.toLowerCase()); + }} /> + + + Password + + + { + setPassword(v.currentTarget.value); + }} /> + + + Your unique handle + So people can @ mention you in discussions. + + + { + setHandle(v.currentTarget.value.toLowerCase()); + }} /> + + + + I am at least 13 years old. + + + + + I agree to the terms and conditions. + + + + Already got an account, login to Wikid + + + + ); +} diff --git a/app/routes/terms.jsx b/app/routes/terms.jsx new file mode 100644 index 0000000..70b9c5a --- /dev/null +++ b/app/routes/terms.jsx @@ -0,0 +1,28 @@ +import { Flex, Heading } from '@radix-ui/themes'; + +export const meta = () => { + return [ + { + title: "Wikid | terms and conditions" + }, + { + property: "og:title", + content: "Wikid | terms and conditions", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export default function Signup() { + return ( + + Terms and conditions + 🥹 + 👷🏻 + ✅ + + ); +} diff --git a/app/routes/u.jsx b/app/routes/u.jsx new file mode 100644 index 0000000..24151d6 --- /dev/null +++ b/app/routes/u.jsx @@ -0,0 +1,96 @@ +import { Flex, Box } from '@radix-ui/themes'; +import { redirect, json } from "@remix-run/node"; +import { authorize } from "../sessions.server.js"; +import { Outlet } from "@remix-run/react"; +import { me, createCommunity, createChannel, leaveCommunity } from "../wikid.server.js"; +import UserMenu from '../components/user-menu.jsx'; + +export const loader = async ({ request }) => { + const jwt = await authorize(request); + + if (!jwt) { + throw redirect('/logout') + } + + const [data, errors] = await me(jwt); + + if (!!errors) { + throw redirect('/logout') + } + + const communities = data.communities; + + if (communities.length == 0 && !request.url.includes("get-started") && !request.url.includes("settings")) { + throw redirect('/c/get-started') + } + + return json({ + path: request.url, + me: data + }); +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const type = body.get("__action"); + + if (type == "create_community") { + + const name = body.get("name"); + const handle = body.get("handle"); + const _private = body.get("private") == "private"; + + const [_, errors] = await createCommunity(name, handle, _private, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect(`/c/${handle}`); + + } else if (type == "create_channel") { + + const communityHandle = body.get("communityHandle"); + const name = body.get("name"); + const groupId = body.get("groupId") == "undefined" ? null : body.get("groupId"); + + const [response, errors] = await createChannel(communityHandle, name, groupId, jwt); + + if (!!errors) { + return json(errors); + } + + if (!!(response?.handle)) { + throw redirect(`/c/${communityHandle}/${response.handle}`); + } + } else if (type == "leave_community") { + const communityHandle = body.get("communityHandle"); + + const [_, errors] = await leaveCommunity(communityHandle, jwt); + + if (!!errors) { + return json(errors); + } + + throw redirect("/c/get-started"); + } + + return json({}) +} + +export default function UserHome() { + return ( + + + + + + + ); +} diff --git a/app/routes/u.settings.jsx b/app/routes/u.settings.jsx new file mode 100644 index 0000000..e85e7d4 --- /dev/null +++ b/app/routes/u.settings.jsx @@ -0,0 +1,335 @@ +import { Flex, Text, TextArea, TextField, Tabs, Box, Button, Link, Checkbox, Select, Avatar, IconButton } from '@radix-ui/themes'; +import { sessionStorage, THEME_SESSION_KEY } from "../sessions.server.js"; +import { redirect, json } from "@remix-run/node"; +import { useCallback, useState, useRef, useEffect } from 'react'; +import { useLoaderData, useSubmit, useNavigation, useActionData, useFetcher } from "@remix-run/react"; +import { updateProfile, me, UNEXPECTED_ERROR_MESSAGE } from "../wikid.server.js"; +import * as Toast from '@radix-ui/react-toast'; +import electron from "../electron.server.js"; +import env from "../environment.server.js"; +import { authorize } from "../sessions.server.js"; +import { friendlyName, avatarFallback } from "../components/helpers.js"; + +export const meta = () => { + return [ + { + title: "Wikid | settings" + }, + { + property: "og:title", + content: "Wikid | settings", + }, + { + name: "description", + content: "Where communities meet", + }, + ]; +}; + +export const loader = async ({ request }) => { + + const jwt = await authorize(request); + + if (!jwt) { + throw redirect('/login'); + } + + let theme = null; + + if (env.electron) { + theme = electron.get(THEME_SESSION_KEY); + } else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + theme = session.get(THEME_SESSION_KEY); + } + + const [data, errors] = await me(jwt); + + if (!!errors) { + throw redirect('/login'); + } + + return json({ + theme: theme ?? "dark", + me: data, + }) +} + +export async function action({ request }) { + const body = await request.formData(); + + const jwt = await authorize(request); + + const action = body.get("__action"); + + if (action == "change_theme") { + + const theme = body.get("theme"); + + if (env.electron) { + electron.set(THEME_SESSION_KEY, theme); + + throw redirect("/u/settings"); + } else { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + session.set(THEME_SESSION_KEY, theme); + + throw redirect("/u/settings", { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 30, + }), + }, + }); + } + } else if (action == "update_profile") { + + const name = body.get("name"); + const handle = body.get("handle"); + const about = body.get("about"); + + const [r, errors] = await updateProfile(name, handle, about, jwt); + + if (!!r?.handle) { + return json({ profileUpdated: true }); + } else if (!!errors) { + return json({ errors: errors }); + } else { + return json({ errors: [{ message: UNEXPECTED_ERROR_MESSAGE }] }); + } + } + + return json({}); +} + +export default function UserSettings() { + + const avatarFetcher = useFetcher({key: "avatar"}); + const data = useLoaderData(); + const navigation = useNavigation(); + const submit = useSubmit(); + const actionData = useActionData(); + const timerRef = useRef(0); + + const hiddenFileInput = useRef(null); + + useEffect(() => { + if (!!avatarFetcher.data?.updated) { + location.reload(); + } + }, [avatarFetcher]); + + const fb = avatarFallback(friendlyName(data.me.name, data.me.handle)); + + const handleProfilePicClick = useCallback(() => { + hiddenFileInput.current.click(); + }, []); + + const handleProfilePicChange = useCallback(async (event) => { + const fileUpload = event.target.files[0]; + const form = new FormData(); + form.append("__action", "update_profile_pic"); + form.append('file', fileUpload); + avatarFetcher.submit(form, { + method: "post", + action: "/z/create-message", + encType: "multipart/form-data" + }) + }, [data]); + + const [name, setName] = useState(data.me.name); + const [handle, setHandle] = useState(data.me.handle); + const [about, setAbout] = useState(data.me.about); + + const [toastOpen, setToastOpen] = useState(false); + const [toast, setToast] = useState(""); + + const [understood, setUnderstood] = useState(false); + + const toggleUnderstood = useCallback(() => { + setUnderstood(v => !v); + }, []); + + const handleProfileChange = useCallback(() => { + window.clearTimeout(timerRef.current); + + const data = { + __action: "update_profile", + name: name, + handle: handle, + about: about, + }; + + submit(data, { method: "post" }); + }, [name, handle, about]); + + const handleThemeChange = useCallback((theme) => { + const data = { + __action: "change_theme", + theme: theme, + }; + + submit(data, { method: "post" }); + }, []); + + useEffect(() => { + if (actionData?.profileUpdated == true) { + setToast("Saved"); + setToastOpen(true); + timerRef.current = window.setTimeout(() => { + setToastOpen(false); + }, 700); + } + }, [actionData]); + + return ( + <> + + + {toast} + + + + + + + + + Profile + Appearance + {/* Notifications */} + Logout + Delete account + + + + + Make changes to your profile. + + + Profile picture + + + + + + + + + + + + + +