2026-06-22 23:06:00 +00:00
|
|
|
<script lang="ts">
|
|
|
|
|
import {derived} from "svelte/store"
|
|
|
|
|
import {page} from "$app/stores"
|
|
|
|
|
import {displayProfileByPubkey} from "@welshman/app"
|
|
|
|
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
|
|
|
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
|
|
|
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
|
|
|
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
|
|
|
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
|
|
|
|
import {fly} from "@lib/transition"
|
|
|
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
|
|
|
import Button from "@lib/components/Button.svelte"
|
|
|
|
|
import Popover from "@lib/components/Popover.svelte"
|
|
|
|
|
import PageContent from "@lib/components/PageContent.svelte"
|
|
|
|
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
|
|
|
|
import SpaceMember from "@app/components/SpaceMember.svelte"
|
|
|
|
|
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
|
|
|
|
import SpaceRoles from "@app/components/SpaceRoles.svelte"
|
|
|
|
|
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
|
|
|
|
|
import {
|
|
|
|
|
deriveSpaceRoles,
|
|
|
|
|
deriveSpaceMembers,
|
|
|
|
|
deriveSpaceMemberRoles,
|
|
|
|
|
deriveUserIsSpaceAdmin,
|
|
|
|
|
type SpaceRole,
|
|
|
|
|
} from "@app/members"
|
|
|
|
|
import {decodeRelay} from "@app/relays"
|
|
|
|
|
import {pushModal} from "@app/modal"
|
|
|
|
|
|
|
|
|
|
const url = decodeRelay($page.params.relay!)
|
|
|
|
|
const roles = deriveSpaceRoles(url)
|
|
|
|
|
const members = deriveSpaceMembers(url)
|
|
|
|
|
const memberRoles = deriveSpaceMemberRoles(url)
|
|
|
|
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
|
|
|
|
|
|
|
|
// Each member with their resolved roles (sorted by order).
|
|
|
|
|
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
|
|
|
|
|
const byId = new Map($roles.map(role => [role.id, role]))
|
|
|
|
|
|
|
|
|
|
return $members.map(pubkey => ({
|
|
|
|
|
pubkey,
|
|
|
|
|
roleList: ($memberRoles.get(pubkey) ?? [])
|
|
|
|
|
.map(id => byId.get(id))
|
|
|
|
|
.filter((role): role is SpaceRole => Boolean(role)),
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let menuOpen = $state(false)
|
|
|
|
|
|
|
|
|
|
const inviteMembers = () => {
|
|
|
|
|
menuOpen = false
|
|
|
|
|
pushModal(SpaceInvite, {url})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const manageRoles = () => {
|
|
|
|
|
menuOpen = false
|
|
|
|
|
pushModal(SpaceRoles, {url})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bannedMembers = () => {
|
|
|
|
|
menuOpen = false
|
|
|
|
|
pushModal(SpaceMembersBanned, {url})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In-place search: filter member cards by member info, and keep role sections
|
|
|
|
|
// whose name matches the term even when their members don't.
|
|
|
|
|
let term = $state("")
|
|
|
|
|
|
|
|
|
|
const matchesTerm = (pubkey: string, t: string) =>
|
|
|
|
|
displayProfileByPubkey(pubkey).toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
|
|
|
|
|
|
|
|
|
|
// In-place search: match by member info or by the name of any role they hold.
|
|
|
|
|
const visibleMembers = $derived.by(() => {
|
|
|
|
|
const t = term.trim().toLowerCase()
|
|
|
|
|
|
|
|
|
|
if (!t) return $memberList
|
|
|
|
|
|
|
|
|
|
return $memberList.filter(
|
|
|
|
|
({pubkey, roleList}) =>
|
|
|
|
|
matchesTerm(pubkey, t) || roleList.some(role => role.label.toLowerCase().includes(t)),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<SpaceBar>
|
|
|
|
|
{#snippet leading()}
|
|
|
|
|
<Icon icon={UsersGroup} />
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet title()}
|
|
|
|
|
<strong>Members</strong>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet action()}
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button class="button button-primary button-sm" onclick={inviteMembers}>
|
2026-06-22 23:06:00 +00:00
|
|
|
<Icon icon={AddCircle} />
|
|
|
|
|
Invite people
|
2026-06-24 20:56:53 +00:00
|
|
|
</Button>
|
2026-06-22 23:06:00 +00:00
|
|
|
{#if $userIsAdmin}
|
|
|
|
|
<div class="relative">
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button
|
|
|
|
|
class="button button-neutral button-sm button-square"
|
2026-06-22 23:06:00 +00:00
|
|
|
aria-label="More options"
|
|
|
|
|
onclick={() => (menuOpen = !menuOpen)}>
|
|
|
|
|
<Icon size={4} icon={MenuDots} />
|
2026-06-24 20:56:53 +00:00
|
|
|
</Button>
|
2026-06-22 23:06:00 +00:00
|
|
|
{#if menuOpen}
|
|
|
|
|
<Popover hideOnClick onClose={() => (menuOpen = false)}>
|
|
|
|
|
<ul
|
|
|
|
|
transition:fly
|
2026-06-24 20:56:53 +00:00
|
|
|
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
|
2026-06-22 23:06:00 +00:00
|
|
|
<li>
|
|
|
|
|
<Button onclick={manageRoles}>
|
|
|
|
|
<Icon icon={UsersGroup} />
|
|
|
|
|
Manage Roles
|
|
|
|
|
</Button>
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
<Button onclick={bannedMembers}>
|
|
|
|
|
<Icon icon={MinusCircle} />
|
|
|
|
|
Banned Members
|
|
|
|
|
</Button>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</Popover>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
|
|
|
|
</SpaceBar>
|
|
|
|
|
|
|
|
|
|
<PageContent class="flex flex-col gap-4 p-4">
|
2026-07-02 18:36:05 +00:00
|
|
|
<label class="input input-group flex w-full items-center gap-2 card">
|
|
|
|
|
<Icon size={4} icon={Magnifier} />
|
|
|
|
|
<input
|
|
|
|
|
bind:value={term}
|
|
|
|
|
class="min-w-0 grow"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search people or roles..." />
|
|
|
|
|
</label>
|
|
|
|
|
{#if visibleMembers.length === 0}
|
|
|
|
|
<p class="flex flex-col items-center py-20 text-center">No members found.</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
{#each visibleMembers as { pubkey, roleList } (pubkey)}
|
|
|
|
|
<SpaceMember {url} {pubkey} roles={roleList} />
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-06-22 23:06:00 +00:00
|
|
|
</PageContent>
|