-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
363 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
Oops, something went wrong.