diff --git a/package.json b/package.json index c8c1ad5..5a398b1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@solid-primitives/media": "^2.1.1", "@solid-primitives/set": "^0.4.2", "@solidjs/router": "^0.13", + "@thisbeyond/solid-dnd": "^0.7.5", "cssnano": "^5.1.14", "firebase": "^10.6", "fuse.js": "^7", diff --git a/src/Entrypoint.tsx b/src/Entrypoint.tsx index 607e460..6a2017e 100644 --- a/src/Entrypoint.tsx +++ b/src/Entrypoint.tsx @@ -58,6 +58,7 @@ const AppearanceSettings = lazy(() => import('./pages/settings/Appearance')) // Guild Settings const GuildSettingsOverview = lazy(() => import('./pages/guilds/settings/Overview')) +const GuildSettingsRoles = lazy(() => import('./pages/guilds/settings/Roles')) const RedirectingLogin = lazy(async () => { const redirectTo = useLocation().pathname @@ -198,7 +199,7 @@ const Entrypoint: Component = () => { - 'wip'} /> + 'wip'} /> diff --git a/src/components/guilds/CreateRoleModal.tsx b/src/components/guilds/CreateRoleModal.tsx new file mode 100644 index 0000000..9dc2d76 --- /dev/null +++ b/src/components/guilds/CreateRoleModal.tsx @@ -0,0 +1,117 @@ +import {ModalTemplate} from "../ui/Modal"; +import {createMemo, createSignal, Show} from "solid-js"; +import {Rgb} from "../../client/themes"; +import PenToSquare from "../icons/svg/PenToSquare"; +import Icon from "../icons/Icon"; +import Plus from "../icons/svg/Plus"; +import {getApi} from "../../api/Api"; +import Palette from "../icons/svg/Palette"; + +interface Props { + guildId: bigint +} + +export default function CreateRoleModal(props: Props) { + const [currentName, setCurrentName] = createSignal("") + + let actualColorInput: HTMLInputElement | null = null + const [currentColor, setCurrentColor] = createSignal() + + const computeColor = (color: string): Rgb => { + const [r, g, b] = color.match(/\w\w/g)!.map(c => parseInt(c, 16)) + return [r, g, b] + } + const fg = createMemo(() => { + const color = currentColor() + if (!color) + return 'fill-fg' + + const [red, green, blue] = color + return (red * 0.299 + green * 0.587 + blue * 0.114) > 186 ? 'fill-black' : 'fill-white' + }) + const hex = createMemo(() => { + const color = currentColor() + return color + ? `#${color.map(c => c.toString(16).padStart(2, '0')).join('')}` + : '#000000' + }) + + const api = getApi()! + const [error, setError] = createSignal("") + const [submitting, setSubmitting] = createSignal(false) + + const onSubmit = async (e: Event) => { + e.preventDefault() + + const color = currentColor() + const name = currentName()! + const json: Record = { name } + + if (color) + json.color = (color[0] << 16) + (color[1] << 8) + color[2] + + setSubmitting(true) + const response = await api.request('POST', `/guilds/${props.guildId}/roles`, { json }) + if (!response.ok) + setError(response.errorJsonOrThrow().message) + + setSubmitting(false) + } + + return ( + +
+
+
actualColorInput!.click()} + > + setCurrentColor(computeColor(e.currentTarget.value))} + /> +
+ +
+
+
+ + { + setCurrentName(e.currentTarget.value) + setError('') + }} + /> +
+
+ +
+ +
{error()}
+
+
+ ) +} diff --git a/src/components/icons/svg/GripDotsVertical.tsx b/src/components/icons/svg/GripDotsVertical.tsx new file mode 100644 index 0000000..a2b8b2b --- /dev/null +++ b/src/components/icons/svg/GripDotsVertical.tsx @@ -0,0 +1,10 @@ +import {JSX} from "solid-js"; + +export default function GripDotsVertical(props: JSX.SvgSVGAttributes) { + return ( + + {/* Font Awesome Pro 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. */} + + + ) +} \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index 432e61a..d6eeace 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -8,6 +8,7 @@ declare module 'solid-js' { namespace JSX { interface Directives { tooltip?: string | Partial; + sortable: boolean; } } } diff --git a/src/pages/guilds/settings/Roles.tsx b/src/pages/guilds/settings/Roles.tsx new file mode 100644 index 0000000..100dd6b --- /dev/null +++ b/src/pages/guilds/settings/Roles.tsx @@ -0,0 +1,231 @@ +import Header from "../../../components/ui/Header"; +import {getApi} from "../../../api/Api"; +import {A, useParams} from "@solidjs/router"; +import {createMemo, createSignal, For, Show, Signal} from "solid-js"; +import { + closestCenter, + createSortable, + DragDropProvider, + DragDropSensors, + DragEventHandler, DragOverlay, SortableProvider, Transformer, + useDragDropContext +} from "@thisbeyond/solid-dnd"; +import {mapIterator, snowflakes, sumIterator} from "../../../utils"; +import {Member, Role} from "../../../types/guild"; +import Icon from "../../../components/icons/Icon"; +import GripDotsVertical from "../../../components/icons/svg/GripDotsVertical"; +import ChevronRight from "../../../components/icons/svg/ChevronRight"; +import withModelType = snowflakes.withModelType; +import ModelType = snowflakes.ModelType; +import {memberKey} from "../../../api/ApiCache"; +import Users from "../../../components/icons/svg/Users"; +import MagnifyingGlass from "../../../components/icons/svg/MagnifyingGlass"; +import Xmark from "../../../components/icons/svg/Xmark"; +import Fuse from "fuse.js"; +import Plus from "../../../components/icons/svg/Plus"; +import Modal from "../../../components/ui/Modal"; +import CreateRoleModal from "../../../components/guilds/CreateRoleModal"; + +function roleColor(provided: number | undefined) { + return provided ? '#' + provided.toString(16) : 'rgb(var(--c-fg) / 0.8)' +} + +function LargeRolePreview(props: { guildId: bigint, members: Member[], role: Role, draggable: boolean }) { + const membersInRole = createMemo(() => sumIterator(mapIterator( + props.members, + (member) => member.roles?.includes(props.role.id) ? 1 : 0 as number + ))) + + const sortable = createSortable(props.role.id.toString()) + const [state] = useDragDropContext() ?? [] + + return ( +
+ +
+ +
+
+ + + ) +} + +export interface Props { + guildId: bigint, + roleIds: Signal, + roles: Role[], +} + +export function SortableRoles(props: Props) { + const cache = getApi()!.cache! + const members = createMemo(() => + cache.memberReactor.get(props.guildId)?.map(id => cache.members.get(memberKey(props.guildId, id))!) ?? [] + ); + const [roleIds, setRoleIds] = props.roleIds + + const [activeRole, setActiveRole] = createSignal() + const onDragStart: DragEventHandler = ({ draggable }) => setActiveRole(draggable.id as string) + const onDragEnd: DragEventHandler = ({ draggable, droppable }) => { + if (draggable && droppable) { + const roles = roleIds() + const fromIndex = roles.indexOf(BigInt(draggable.id)) + const toIndex = roles.indexOf(BigInt(droppable.id)) + + if (fromIndex !== toIndex) { + const newRoles = [...roles]; + [newRoles[fromIndex], newRoles[toIndex]] = [newRoles[toIndex], newRoles[fromIndex]] + setRoleIds(newRoles) + } + } + } + + const ConstrainDragAxis = () => { + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = useDragDropContext()! + + const transformer: Transformer = { + id: "constrain-x-axis", + order: 100, + callback: (transform) => ({ ...transform, x: 0 }), + }; + + onDragStart(({ draggable }) => { + addTransformer("draggables", draggable.id, transformer); + }); + + onDragEnd(({ draggable }) => { + removeTransformer("draggables", draggable.id, transformer.id); + }); + + return <>; + }; + + let searchRef: HTMLInputElement | null + const [searchQuery, setSearchQuery] = createSignal('') + + const index = createMemo(() => new Fuse(props.roles, { keys: ['name'] })) + const queryResults = createMemo(() => searchQuery() + ? index().search(searchQuery()).map(result => result.item.id) + : roleIds() + ) + + const [showCreateRoleModal, setShowCreateRoleModal] = createSignal(false) + + return ( + + + + + + +
+
+
+ + setSearchQuery(event.currentTarget.value)} + /> + + { + setSearchQuery('') + searchRef!.focus() + }} + /> + +
+ +
+ r.toString())}> + + {roleId => ( + + )} + + + +
+ + Manage Default Permissions +
+ +
+
+ + + + + +
+ ) +} + +export default function Roles() { + const params = useParams() + + const api = getApi()! + const guildId = createMemo(() => BigInt(params.guildId)) + const guild = createMemo(() => api.cache!.guilds.get(guildId())!) + + const defaultRoleId = createMemo(() => withModelType(guildId(), ModelType.Role)) + const guildRoles = createMemo(() => guild().roles!.filter(r => r.id != defaultRoleId())) + + const roleIds = createSignal(guildRoles().map(r => r.id).reverse()) + + return ( +
+
Roles
+

+ Roles are used to group members in your server and grant them permissions. +

+ +
+ ) +} \ No newline at end of file diff --git a/src/types/guild.ts b/src/types/guild.ts index 83f7250..91562a7 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -69,7 +69,7 @@ export interface Guild extends PartialGuild { /** * A list of resolved roles in the guild. */ - roles?: any // TODO Role[]; + roles?: Role[]; /** * A list of resolved channels in the guild. */