Skip to content

Commit

Permalink
role settings panel and modal
Browse files Browse the repository at this point in the history
  • Loading branch information
jay3332 committed May 21, 2024
1 parent eafb243 commit 6d8a53a
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 2 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/Entrypoint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -198,7 +199,7 @@ const Entrypoint: Component = () => {
<Route path="/settings" component={SettingsRoot} />
<Route path="/guilds/:guildId/settings" component={GuildSettings}>
<Route path="/overview" component={GuildSettingsOverview} />
<Route path="/roles" component={() => 'wip'} />
<Route path="/roles" component={GuildSettingsRoles} />
<Route path="/invites" component={() => 'wip'} />
</Route>
<Route path="/guilds/:guildId/settings" component={GuildSettingsRoot} />
Expand Down
117 changes: 117 additions & 0 deletions src/components/guilds/CreateRoleModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("")

let actualColorInput: HTMLInputElement | null = null
const [currentColor, setCurrentColor] = createSignal<Rgb>()

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<string>("")
const [submitting, setSubmitting] = createSignal(false)

const onSubmit = async (e: Event) => {
e.preventDefault()

const color = currentColor()
const name = currentName()!
const json: Record<string, any> = { 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 (
<ModalTemplate title="Create Role">
<form onSubmit={onSubmit} class="flex flex-col gap-y-2 pt-6">
<div class="flex items-center gap-x-2">
<div
class="group/palette rounded-lg overflow-hidden w-14 h-14 relative hover:cursor-pointer outline outline-fg/20"
style={{ "background-color": currentColor() ? `rgb(${currentColor()?.join(' ')})` : 'transparent' }}
onClick={() => actualColorInput!.click()}
>
<input
ref={actualColorInput!}
type="color"
class="absolute invisible inset-0 w-full h-full"
value={hex()}
onInput={e => setCurrentColor(computeColor(e.currentTarget.value))}
/>
<div
classList={{
"absolute inset-0 transition flex items-center justify-center group-hover/palette:opacity-100": true,
[currentColor() ? "opacity-0" : "opacity-60"]: true,
}}
>
<Icon icon={currentColor() ? PenToSquare : Palette} class={`w-6 h-6 ${fg()}`} title="Edit Color" />
</div>
</div>
<div class="flex flex-col flex-grow gap-y-1">
<label class="text-fg/60 text-xs font-bold uppercase">Role Name</label>
<input
type="text"
class="input flex-grow"
placeholder="Role Name"
minLength={2}
maxLength={32}
required={true}
value={currentName()}
onInput={(e) => {
setCurrentName(e.currentTarget.value)
setError('')
}}
/>
</div>
</div>
<button
type="submit"
class="btn btn-primary flex-grow disabled:bg-accent/50 disabled:text-opacity-50"
disabled={!currentName() || submitting()}
>
<Icon icon={Plus} class="fill-fg w-4 h-4 mr-2" />
<span>Create Role</span>
</button>
</form>
<Show when={error()}>
<div class="text-red-600 mt-2">{error()}</div>
</Show>
</ModalTemplate>
)
}
10 changes: 10 additions & 0 deletions src/components/icons/svg/GripDotsVertical.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {JSX} from "solid-js";

export default function GripDotsVertical(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512" {...props}>
{/* Font Awesome Pro 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. */}
<path d="M48 144a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm0 160a48 48 0 1 0 0-96 48 48 0 1 0 0 96zM96 416A48 48 0 1 0 0 416a48 48 0 1 0 96 0zM208 144a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm48 112a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM208 464a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/>
</svg>
)
}
1 change: 1 addition & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ declare module 'solid-js' {
namespace JSX {
interface Directives {
tooltip?: string | Partial<tippy.Props>;
sortable: boolean;
}
}
}
Expand Down
231 changes: 231 additions & 0 deletions src/pages/guilds/settings/Roles.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
ref={sortable.ref}
classList={{
"flex rounded-lg overflow-hidden": true,
"opacity-25": sortable.isActiveDraggable,
"transition-transform": !!state?.active.draggable,
}}
>
<Show when={props.draggable}>
<div
class="flex group items-center justify-center px-3 bg-bg-0 transition hover:bg-3 cursor-grab"
{...sortable.dragActivators}
>
<Icon
icon={GripDotsVertical}
class="w-6 h-6 fill-fg/50 transition-all group-hover:fill-fg/80"
/>
</div>
</Show>
<A
class="flex flex-grow bg-bg-1/80 hover:bg-3 transition items-center justify-between p-4"
href={`/guilds/${props.guildId}/settings/roles/${props.role.id}`}
>
<div class="flex items-center gap-x-4">
<div class="w-4 h-4 rounded-full" style={{ background: roleColor(props.role.color) }} />
<div class="flex-grow">
<h3 class="text-lg font-title">{props.role.name}</h3>
<p class="text-fg/60 text-sm">{membersInRole()} members</p>
</div>
</div>
<Icon icon={ChevronRight} class="w-6 h-6 fill-fg/50" />
</A>
</div>
)
}

export interface Props {
guildId: bigint,
roleIds: Signal<bigint[]>,
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<string>()
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 (
<DragDropProvider onDragStart={onDragStart} onDragEnd={onDragEnd} collisionDetector={closestCenter}>
<Modal get={showCreateRoleModal} set={setShowCreateRoleModal}>
<CreateRoleModal guildId={props.guildId} />
</Modal>
<DragDropSensors />
<ConstrainDragAxis />
<div class="flex flex-col w-full gap-y-2">
<div class="flex gap-x-2">
<div class="flex flex-grow bg-bg-3/60 rounded-lg items-center">
<Icon icon={MagnifyingGlass} class="w-4 h-4 fill-fg/50 my-3 ml-3" />
<input
type="text"
class="w-full p-2 outline-none font-medium bg-transparent"
placeholder="Search Roles"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
/>
<Show when={searchQuery()}>
<Icon
icon={Xmark}
class="w-4 h-4 fill-fg/50 mr-3 cursor-pointer hover:fill-fg/80 transition duration-200"
onClick={() => {
setSearchQuery('')
searchRef!.focus()
}}
/>
</Show>
</div>
<button class="btn btn-primary btn-sm flex gap-x-1" onClick={() => setShowCreateRoleModal(true)}>
<Icon icon={Plus} class="w-4 h-4 fill-fg" />
<span class="mobile:hidden">Create Role</span>
</button>
</div>
<SortableProvider ids={roleIds().map(r => r.toString())}>
<For each={queryResults()}>
{roleId => (
<LargeRolePreview
guildId={props.guildId}
members={members()}
role={cache.roles.get(roleId)!}
draggable={searchQuery() === ''}
/>
)}
</For>
</SortableProvider>
<A
class="bg-bg-0/70 flex justify-between rounded-lg items-center p-4 transition hover:bg-3"
href={`/guilds/${props.guildId}/settings/roles/${withModelType(props.guildId, ModelType.Role)}`}
>
<div class="flex items-center gap-x-3 font-title">
<Icon icon={Users} class="w-6 h-6 fill-fg/80" />
Manage Default Permissions
</div>
<Icon icon={ChevronRight} class="w-5 h-5 fill-fg/50" />
</A>
</div>
<DragOverlay>
<Show when={activeRole()}>
<LargeRolePreview
guildId={props.guildId}
members={members()}
role={cache.roles.get(BigInt(activeRole()!))!}
draggable={false}
/>
</Show>
</DragOverlay>
</DragDropProvider>
)
}

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<bigint[]>(guildRoles().map(r => r.id).reverse())

return (
<div class="px-3 py-2">
<Header>Roles</Header>
<p class="mb-4 px-1 font-light text-sm text-fg/50">
Roles are used to group members in your server and grant them permissions.
</p>
<SortableRoles guildId={guildId()} roleIds={roleIds} roles={guildRoles()} />
</div>
)
}
Loading

0 comments on commit 6d8a53a

Please sign in to comment.