forked from coracle/flotilla
Add roles
This commit is contained in:
@@ -170,6 +170,9 @@ src/
|
|||||||
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates
|
||||||
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
|
||||||
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
|
||||||
|
- Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileInfo` component instead.
|
||||||
|
- Use `type Props` instead of interface when defining props for svelte components.
|
||||||
|
- When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's `<script module>` (e.g. a `Values` type) for callers to import, instead of re-enumerating its sub-properties.
|
||||||
|
|
||||||
**Human-First Simplicity (Jon Staab Style):**
|
**Human-First Simplicity (Jon Staab Style):**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -414,11 +414,11 @@ progress[value]::-webkit-progress-value {
|
|||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.left-content {
|
.left-content {
|
||||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
@apply left-sai md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-content-full {
|
.left-content-full {
|
||||||
@apply md:left-[calc(3.5rem+var(--sail))];
|
@apply left-sai md:left-[calc(3.5rem+var(--sail))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard open state adjustments */
|
/* Keyboard open state adjustments */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
import {fromNostrURI} from "@welshman/util"
|
import {fromNostrURI} from "@welshman/util"
|
||||||
import {nthEq} from "@welshman/lib"
|
import {nthEq} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
@@ -37,10 +38,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
event: any
|
event: any
|
||||||
trimParent?: boolean
|
trimParent?: boolean
|
||||||
|
singleLine?: boolean
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {event, trimParent = false, url}: Props = $props()
|
const {event, trimParent = false, singleLine = false, url}: Props = $props()
|
||||||
|
|
||||||
const fullContent = parse(event)
|
const fullContent = parse(event)
|
||||||
|
|
||||||
@@ -104,10 +106,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden text-ellipsis wrap-break-word">
|
<div
|
||||||
|
class={cx(
|
||||||
|
"overflow-hidden text-ellipsis",
|
||||||
|
singleLine ? "whitespace-nowrap" : "wrap-break-word",
|
||||||
|
)}>
|
||||||
{#each shortContent as parsed, i}
|
{#each shortContent as parsed, i}
|
||||||
{#if isNewline(parsed)}
|
{#if isNewline(parsed)}
|
||||||
<ContentNewline value={parsed.value} />
|
{#if singleLine}
|
||||||
|
{" "}
|
||||||
|
{:else}
|
||||||
|
<ContentNewline value={parsed.value} />
|
||||||
|
{/if}
|
||||||
{:else if isTopic(parsed)}
|
{:else if isTopic(parsed)}
|
||||||
<ContentTopic value={parsed.value} />
|
<ContentTopic value={parsed.value} />
|
||||||
{:else if isEmoji(parsed)}
|
{:else if isEmoji(parsed)}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block border-r border-solid border-base-content/15 dark:border-base-content/10">
|
||||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
{#if PLATFORM_RELAYS.length > 0}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
const copyPubkey = () => clip(nip19.npubEncode(pubkey))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex max-w-full items-start gap-3">
|
<div class="flex max-w-full items-start gap-2">
|
||||||
{#if inert}
|
{#if inert}
|
||||||
<span class="py-1">
|
<span class="py-1">
|
||||||
<ProfileCircle {pubkey} size={avatarSize} />
|
<ProfileCircle {pubkey} size={avatarSize} />
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if inert}
|
{#if inert}
|
||||||
<span class="text-bold overflow-hidden text-ellipsis">
|
<span class="text-bold overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{$profileDisplay}
|
{$profileDisplay}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {removeUndefined} from "@welshman/lib"
|
import {removeUndefined} from "@welshman/lib"
|
||||||
import {ManagementMethod} from "@welshman/util"
|
import {deriveProfile, displayProfileByPubkey, loadMessagingRelayList} from "@welshman/app"
|
||||||
import {
|
|
||||||
manageRelay,
|
|
||||||
deriveProfile,
|
|
||||||
displayProfileByPubkey,
|
|
||||||
loadMessagingRelayList,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||||
@@ -29,7 +23,12 @@
|
|||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
import {pubkeyLink} from "@app/env"
|
import {pubkeyLink} from "@app/env"
|
||||||
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
|
import {
|
||||||
|
deriveUserIsSpaceAdmin,
|
||||||
|
deriveSpaceBannedPubkeyItems,
|
||||||
|
addSpaceMembers,
|
||||||
|
banSpaceMembers,
|
||||||
|
} from "@app/members"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {goToChat} from "@app/routes"
|
import {goToChat} from "@app/routes"
|
||||||
@@ -68,10 +67,7 @@
|
|||||||
title: "Ban User",
|
title: "Ban User",
|
||||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||||
confirm: async () => {
|
confirm: async () => {
|
||||||
const {error} = await manageRelay(url!, {
|
const error = await banSpaceMembers(url!, [pubkey])
|
||||||
method: ManagementMethod.BanPubkey,
|
|
||||||
params: [pubkey],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
|
singleLine?: boolean
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url, singleLine}: Props = $props()
|
||||||
|
|
||||||
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
const profile = deriveProfile(pubkey, removeUndefined([url]))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $profile}
|
{#if $profile}
|
||||||
<ContentMinimal event={{content: $profile.about || "", tags: []}} />
|
<ContentMinimal event={{content: $profile.about || "", tags: []}} {singleLine} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
import {deriveUserIsSpaceAdmin, banSpaceMembers} from "@app/members"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {publishDelete} from "@app/deletes"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {canEnforceNip70} from "@app/relays"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
@@ -91,10 +91,7 @@
|
|||||||
title: "Ban User",
|
title: "Ban User",
|
||||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||||
confirm: async () => {
|
confirm: async () => {
|
||||||
const {error} = await manageRelay(url, {
|
const error = await banSpaceMembers(url, [pubkey], reason)
|
||||||
method: ManagementMethod.BanPubkey,
|
|
||||||
params: [pubkey, reason],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|||||||
+27
-16
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -12,43 +11,55 @@
|
|||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||||
import {addSpaceMembers} from "@app/members"
|
import {addSpaceMembers, assignRole, type SpaceRole} from "@app/members"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
|
role: SpaceRole
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url, role}: Props = $props()
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const addMember = async () => {
|
let loading = $state(false)
|
||||||
|
let pubkeys: string[] = $state([])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const error = await addSpaceMembers(url, pubkeys)
|
// Ensure they're space members first, then assign the role
|
||||||
|
const memberError = await addSpaceMembers(url, pubkeys)
|
||||||
|
|
||||||
if (error) {
|
if (memberError) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: memberError})
|
||||||
} else {
|
return
|
||||||
pushToast({message: "Members have successfully been added!"})
|
|
||||||
back()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const pubkey of pubkeys) {
|
||||||
|
const error = await assignRole(url, pubkey, role.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Members assigned!"})
|
||||||
|
back()
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let loading = $state(false)
|
|
||||||
let pubkeys: string[] = $state([])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Add Members</ModalTitle>
|
<ModalTitle>Add to {role.label || "Role"}</ModalTitle>
|
||||||
<ModalSubtitle>to {displayRelayUrl(url)}</ModalSubtitle>
|
<ModalSubtitle>Assign members to this role</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
@@ -64,7 +75,7 @@
|
|||||||
<Icon icon={AltArrowLeft} />
|
<Icon icon={AltArrowLeft} />
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="btn btn-primary" onclick={addMember} disabled={loading}>
|
<Button class="btn btn-primary" onclick={submit} disabled={loading || pubkeys.length === 0}>
|
||||||
<Spinner {loading}>Save changes</Spinner>
|
<Spinner {loading}>Save changes</Spinner>
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {roleColor, roleColorSoft, type SpaceRole} from "@app/members"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: SpaceRole
|
||||||
|
}
|
||||||
|
|
||||||
|
const {role}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="badge min-w-0"
|
||||||
|
style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor(
|
||||||
|
role.color,
|
||||||
|
)}; color: {roleColor(role.color)};">
|
||||||
|
<strong>{role.label || "Untitled Role"}</strong>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {randomId} from "@welshman/lib"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
|
||||||
|
import {createRole} from "@app/members"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
const onSubmit = async ({label, description, color}: Values) => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await createRole(url, randomId(), label, description, color, 0)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Role created!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Create Role</ModalTitle>
|
||||||
|
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<RoleForm {loading} {onSubmit} />
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import RoleForm, {type Values} from "@app/components/RoleForm.svelte"
|
||||||
|
import {editRole, type SpaceRole} from "@app/members"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
role: SpaceRole
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, role}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
const onSubmit = async ({label, description, color}: Values) => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await editRole(url, role.id, label, description, color, role.order)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Role updated!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Edit Role</ModalTitle>
|
||||||
|
<ModalSubtitle>in <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<RoleForm {loading} {onSubmit} initialValues={role} />
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
import type {SpaceRole} from "@app/members"
|
||||||
|
|
||||||
|
export type Values = Pick<SpaceRole, "label" | "description" | "color">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {roleColor} from "@app/members"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialValues?: Partial<Values>
|
||||||
|
loading?: boolean
|
||||||
|
onSubmit: (values: Values) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {initialValues = {}, loading = false, onSubmit}: Props = $props()
|
||||||
|
|
||||||
|
const values: Values = $state({
|
||||||
|
label: "",
|
||||||
|
description: "",
|
||||||
|
color: Math.floor(Math.random() * 256),
|
||||||
|
...initialValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const submit = () => onSubmit(values)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Name</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<input
|
||||||
|
bind:value={values.label}
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Moderator" />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Description</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<textarea bind:value={values.description} class="textarea textarea-bordered w-full" rows="2"
|
||||||
|
></textarea>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Color</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 shrink-0 rounded-full border-2 border-base-300"
|
||||||
|
style="background-color: {roleColor(values.color)}">
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="255"
|
||||||
|
bind:value={values.color}
|
||||||
|
class="range range-sm grow"
|
||||||
|
style="color: {roleColor(values.color)}; --range-shdw: {roleColor(values.color)}" />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" onclick={submit} disabled={loading || !values.label}>
|
||||||
|
<Spinner {loading}>Save changes</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import RoleBadge from "@app/components/RoleBadge.svelte"
|
||||||
|
import type {SpaceRole} from "@app/members"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: SpaceRole
|
||||||
|
}
|
||||||
|
|
||||||
|
const {role}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<RoleBadge {role} />
|
||||||
|
{#if role.description}
|
||||||
|
<p class="text-sm opacity-70">{role.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<div class="flex min-w-0 items-start gap-2">
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
||||||
<div class="hidden shrink-0 md:flex md:items-center">
|
<div class="hidden shrink-0 md:flex md:items-center place-self-center">
|
||||||
{@render leading?.()}
|
{@render leading?.()}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
|
||||||
import {deriveRelay} from "@welshman/app"
|
|
||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
|
||||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
|
||||||
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
|
||||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
|
||||||
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
|
|
||||||
import RelayDescription from "@app/components/RelayDescription.svelte"
|
|
||||||
import ProfileLatest from "@app/components/ProfileLatest.svelte"
|
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url}: Props = $props()
|
|
||||||
const relay = deriveRelay(url)
|
|
||||||
const owner = $derived($relay?.pubkey)
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal>
|
|
||||||
<ModalBody>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<div class="relative flex gap-4">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="avatar relative">
|
|
||||||
<div
|
|
||||||
class="center flex! h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
|
|
||||||
<RelayIcon {url} size={10} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
|
||||||
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
|
|
||||||
<RelayName {url} />
|
|
||||||
</h1>
|
|
||||||
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if $userIsAdmin}
|
|
||||||
<Button class="btn btn-primary" onclick={startEdit}>
|
|
||||||
<Icon icon={Pen} />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<RelayDescription {url} />
|
|
||||||
{#if $relay?.terms_of_service || $relay?.privacy_policy}
|
|
||||||
<div class="flex gap-3">
|
|
||||||
{#if $relay.terms_of_service}
|
|
||||||
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
|
|
||||||
<Icon icon={BillList} size={4} />
|
|
||||||
Terms of Service
|
|
||||||
</Link>
|
|
||||||
{/if}
|
|
||||||
{#if $relay.privacy_policy}
|
|
||||||
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
|
|
||||||
<Icon icon={ShieldUser} size={4} />
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<SpaceRelayStatus {url} />
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#if owner}
|
|
||||||
<div class="card2 bg-alt">
|
|
||||||
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<Icon icon={UserRounded} />
|
|
||||||
Latest Updates
|
|
||||||
</h3>
|
|
||||||
<ProfileLatest {url} pubkey={owner}>
|
|
||||||
{#snippet fallback()}
|
|
||||||
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
|
|
||||||
{/snippet}
|
|
||||||
</ProfileLatest>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={back}>
|
|
||||||
<Icon icon={AltArrowLeft} />
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
+56
-6
@@ -1,9 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
|
||||||
import {deriveRelay} from "@welshman/app"
|
import {deriveRelay} from "@welshman/app"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
|
||||||
import Server from "@assets/icons/server.svg?dataurl"
|
import Server from "@assets/icons/server.svg?dataurl"
|
||||||
|
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||||
|
|
||||||
|
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
|
||||||
|
import RelayDescription from "@app/components/RelayDescription.svelte"
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||||
|
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -12,16 +30,48 @@
|
|||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
|
|
||||||
|
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-4">
|
<div class="card2 bg-alt flex flex-col gap-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex justify-between">
|
||||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
<div class="relative flex gap-4">
|
||||||
<Icon icon={Server} />
|
<div class="relative">
|
||||||
Relay Details
|
<RelayIcon {url} size={14} class="rounded-full" />
|
||||||
</h3>
|
</div>
|
||||||
<SocketStatusIndicator {url} />
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<h1 class="ellipsize whitespace-nowrap">
|
||||||
|
<RelayName {url} class="text-2xl font-bold" />
|
||||||
|
</h1>
|
||||||
|
<p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if $userIsAdmin}
|
||||||
|
<Button class="btn btn-primary" onclick={startEdit}>
|
||||||
|
<Icon icon={Pen} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<RelayDescription {url} />
|
||||||
|
{#if $relay?.terms_of_service || $relay?.privacy_policy}
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#if $relay.terms_of_service}
|
||||||
|
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
|
||||||
|
<Icon icon={BillList} size={4} />
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
{#if $relay.privacy_policy}
|
||||||
|
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
|
||||||
|
<Icon icon={ShieldUser} size={4} />
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if $relay}
|
{#if $relay}
|
||||||
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
|
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Edit a Space</ModalTitle>
|
<ModalTitle>Edit this Space</ModalTitle>
|
||||||
<ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
|
<ModalSubtitle><span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
|
|||||||
@@ -2,29 +2,36 @@
|
|||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {sleep} from "@welshman/lib"
|
import {sleep} from "@welshman/lib"
|
||||||
import {request} from "@welshman/net"
|
import {request} from "@welshman/net"
|
||||||
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
|
import {displayRelayUrl, getTagValue, ManagementMethod, RELAY_INVITE} from "@welshman/util"
|
||||||
import {Share} from "@capacitor/share"
|
import {Share} from "@capacitor/share"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Upload from "@assets/icons/upload.svg?dataurl"
|
import Upload from "@assets/icons/upload.svg?dataurl"
|
||||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||||
import QRCode from "@app/components/QRCode.svelte"
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
import {clip} from "@app/toast"
|
import {clip, pushToast} from "@app/toast"
|
||||||
import {PLATFORM_URL} from "@app/env"
|
import {PLATFORM_URL} from "@app/env"
|
||||||
import {deriveRelayAuthError} from "@app/relays"
|
import {deriveRelayAuthError, deriveSupportedMethods} from "@app/relays"
|
||||||
|
import {addSpaceMembers} from "@app/members"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
|
const supportedMethods = deriveSupportedMethods(url)
|
||||||
|
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
|
||||||
const authError = deriveRelayAuthError(url)
|
const authError = deriveRelayAuthError(url)
|
||||||
|
|
||||||
let networkError = $state(false)
|
let networkError = $state(false)
|
||||||
const isExplicitAuthError = $derived(
|
const isExplicitAuthError = $derived(
|
||||||
$authError &&
|
$authError &&
|
||||||
@@ -54,6 +61,28 @@
|
|||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let invite = $state("")
|
let invite = $state("")
|
||||||
|
|
||||||
|
let adding = $state(false)
|
||||||
|
let pubkeys: string[] = $state([])
|
||||||
|
|
||||||
|
const addMembers = async () => {
|
||||||
|
if (pubkeys.length === 0) return
|
||||||
|
|
||||||
|
adding = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await addSpaceMembers(url, pubkeys)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Members have successfully been added!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const relay = displayRelayUrl(url)
|
const relay = displayRelayUrl(url)
|
||||||
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
||||||
@@ -124,7 +153,7 @@
|
|||||||
<div class="flex w-full gap-2">
|
<div class="flex w-full gap-2">
|
||||||
{#if canShare}
|
{#if canShare}
|
||||||
<Button
|
<Button
|
||||||
class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0"
|
class="input input-bordered flex w-12 shrink-0 items-center justify-center p-0"
|
||||||
onclick={shareInvite}>
|
onclick={shareInvite}>
|
||||||
<Icon icon={Upload} />
|
<Icon icon={Upload} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -152,8 +181,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if canAddMembers}
|
||||||
|
<Divider>or</Divider>
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Add members directly</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ProfileMultiSelect bind:value={pubkeys} />
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
{#if canAddMembers}
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={addMembers}
|
||||||
|
disabled={adding || pubkeys.length === 0}>
|
||||||
|
<Spinner loading={adding}>Save</Spinner>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="btn btn-primary grow" onclick={back}>Done</Button>
|
||||||
|
{/if}
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {ManagementMethod} from "@welshman/util"
|
||||||
|
import {displayProfileByPubkey} from "@welshman/app"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
|
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||||
|
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
||||||
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import Profile from "@app/components/Profile.svelte"
|
||||||
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
|
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
|
||||||
|
import RoleBadge from "@app/components/RoleBadge.svelte"
|
||||||
|
import {removeSpaceMembers, banSpaceMembers, type SpaceRole} from "@app/members"
|
||||||
|
import {deriveSupportedMethods} from "@app/relays"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {goToChat} from "@app/routes"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
pubkey: string
|
||||||
|
roles?: SpaceRole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, pubkey, roles = []}: Props = $props()
|
||||||
|
|
||||||
|
const supportedMethods = deriveSupportedMethods(url)
|
||||||
|
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
|
||||||
|
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
|
||||||
|
const canAssign = $derived($supportedMethods.some(m => (m as string) === "assignrole"))
|
||||||
|
const canUnassign = $derived($supportedMethods.some(m => (m as string) === "unassignrole"))
|
||||||
|
|
||||||
|
let menuOpen = $state(false)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
const closeMenu = () => (menuOpen = false)
|
||||||
|
|
||||||
|
const openProfile = () => {
|
||||||
|
menuOpen = false
|
||||||
|
pushModal(ProfileDetail, {pubkey, url})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
menuOpen = false
|
||||||
|
goToChat([pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRoles = () => {
|
||||||
|
menuOpen = false
|
||||||
|
pushModal(SpaceMemberRoles, {url, pubkey})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMember = () => {
|
||||||
|
menuOpen = false
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Remove Member",
|
||||||
|
message: `Remove @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||||
|
confirm: async () => {
|
||||||
|
const error = await removeSpaceMembers(url, [pubkey])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Member has successfully been removed!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const banMember = () => {
|
||||||
|
menuOpen = false
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Ban Member",
|
||||||
|
message: `Ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
||||||
|
confirm: async () => {
|
||||||
|
const error = await banSpaceMembers(url, [pubkey])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Member has successfully been banned!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 card2-sm border border-solid border-base-content/20 relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-0 cursor-pointer rounded-box"
|
||||||
|
aria-label="View {displayProfileByPubkey(pubkey)}'s profile"
|
||||||
|
onclick={openProfile}>
|
||||||
|
</button>
|
||||||
|
<div class="pointer-events-none relative flex items-start justify-between gap-2">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<Profile {pubkey} {url} inert />
|
||||||
|
{#if roles.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each roles as role (role.id)}
|
||||||
|
<RoleBadge {role} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="line-clamp-1 text-sm opacity-70">
|
||||||
|
<ProfileInfo {pubkey} {url} singleLine />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-auto relative shrink-0">
|
||||||
|
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
|
||||||
|
<Icon icon={MenuDots} />
|
||||||
|
</Button>
|
||||||
|
{#if menuOpen}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
<li>
|
||||||
|
<Button onclick={sendMessage}>
|
||||||
|
<Icon icon={Letter} />
|
||||||
|
Send message
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{#if canAssign || canUnassign}
|
||||||
|
<li>
|
||||||
|
<Button onclick={editRoles}>
|
||||||
|
<Icon icon={Pen} />
|
||||||
|
Edit roles
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if canUnallow}
|
||||||
|
<li>
|
||||||
|
<Button onclick={removeMember}>
|
||||||
|
<Icon icon={UserMinus} />
|
||||||
|
Remove member
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if canBan}
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={banMember}>
|
||||||
|
<Icon icon={MinusCircle} />
|
||||||
|
Ban member
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {get} from "svelte/store"
|
||||||
|
import {displayProfileByPubkey} from "@welshman/app"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import RoleItem from "@app/components/RoleItem.svelte"
|
||||||
|
import {deriveSpaceRoles, deriveSpaceMemberRoles, assignRole, unassignRole} from "@app/members"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, pubkey}: Props = $props()
|
||||||
|
|
||||||
|
const roles = deriveSpaceRoles(url)
|
||||||
|
const memberRoles = deriveSpaceMemberRoles(url)
|
||||||
|
const initial = new Set(get(memberRoles).get(pubkey) ?? [])
|
||||||
|
|
||||||
|
let selected = $state(new Set(initial))
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const id of selected) {
|
||||||
|
if (!initial.has(id)) {
|
||||||
|
const error = await assignRole(url, pubkey, id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of initial) {
|
||||||
|
if (!selected.has(id)) {
|
||||||
|
const error = await unassignRole(url, pubkey, id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Roles updated!"})
|
||||||
|
back()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Edit Member</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
Manage roles for <span class="text-primary">@{displayProfileByPubkey(pubkey)}</span>
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
{#if $roles.length === 0}
|
||||||
|
<div class="card2 bg-base-200 p-4 text-sm opacity-70">This space has no roles yet.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each $roles as role (role.id)}
|
||||||
|
<label class="card2 card2-sm bg-alt flex cursor-pointer items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={selected.has(role.id)}
|
||||||
|
onchange={() => toggle(role.id)} />
|
||||||
|
<RoleItem {role} />
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
|
||||||
|
<Spinner {loading}>Save changes</Spinner>
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {ManagementMethod} from "@welshman/util"
|
|
||||||
import {manageRelay, displayProfileByPubkey} from "@welshman/app"
|
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
|
||||||
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
|
||||||
import Profile from "@app/components/Profile.svelte"
|
|
||||||
import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte"
|
|
||||||
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
|
|
||||||
import {
|
|
||||||
deriveSpaceMembers,
|
|
||||||
deriveSpaceBannedPubkeyItems,
|
|
||||||
deriveUserIsSpaceAdmin,
|
|
||||||
} from "@app/members"
|
|
||||||
import {deriveSupportedMethods} from "@app/relays"
|
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url}: Props = $props()
|
|
||||||
|
|
||||||
const members = deriveSpaceMembers(url)
|
|
||||||
const bans = deriveSpaceBannedPubkeyItems(url)
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
||||||
const supportedMethods = deriveSupportedMethods(url)
|
|
||||||
const canBan = $derived($supportedMethods.includes(ManagementMethod.BanPubkey))
|
|
||||||
const canUnallow = $derived($supportedMethods.includes(ManagementMethod.UnallowPubkey))
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const toggleMenu = (pubkey: string) => {
|
|
||||||
menuPubkey = menuPubkey === pubkey ? undefined : pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMenu = () => {
|
|
||||||
menuPubkey = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const showBannedPubkeyItems = () => pushModal(SpaceMembersBanned, {url})
|
|
||||||
|
|
||||||
const addMember = () => pushModal(SpaceMembersAdd, {url})
|
|
||||||
|
|
||||||
const unallowMember = (pubkey: string) =>
|
|
||||||
pushModal(Confirm, {
|
|
||||||
title: "Remove User",
|
|
||||||
message: `Are you sure you want to remove @${displayProfileByPubkey(pubkey)} from the space?`,
|
|
||||||
confirm: async () => {
|
|
||||||
const {error} = await manageRelay(url, {
|
|
||||||
method: ManagementMethod.UnallowPubkey,
|
|
||||||
params: [pubkey],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
pushToast({theme: "error", message: error})
|
|
||||||
} else {
|
|
||||||
pushToast({message: "User has successfully been removed!"})
|
|
||||||
back()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const banMember = (pubkey: string) =>
|
|
||||||
pushModal(Confirm, {
|
|
||||||
title: "Ban User",
|
|
||||||
message: `Are you sure you want to ban @${displayProfileByPubkey(pubkey)} from the space?`,
|
|
||||||
confirm: async () => {
|
|
||||||
const {error} = await manageRelay(url, {
|
|
||||||
method: ManagementMethod.BanPubkey,
|
|
||||||
params: [pubkey],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
pushToast({theme: "error", message: error})
|
|
||||||
} else {
|
|
||||||
pushToast({message: "User has successfully been banned!"})
|
|
||||||
back()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
let menuPubkey = $state<string | undefined>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal>
|
|
||||||
<ModalBody>
|
|
||||||
<ModalHeader>
|
|
||||||
<ModalTitle>Members</ModalTitle>
|
|
||||||
<ModalSubtitle>of <RelayName {url} class="text-primary" /></ModalSubtitle>
|
|
||||||
</ModalHeader>
|
|
||||||
{#if $userIsAdmin}
|
|
||||||
{#if $bans.length > 0}
|
|
||||||
<Button class="btn btn-neutral" onclick={showBannedPubkeyItems}>
|
|
||||||
Banned users ({$bans.length})
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#if $members === undefined}
|
|
||||||
<div class="card2 bg-base-200 p-4">
|
|
||||||
<span class="text-error">Member list not available from this space</span>
|
|
||||||
</div>
|
|
||||||
{:else if $members.length === 0}
|
|
||||||
<div class="card2 bg-base-200 p-4">
|
|
||||||
<span class="text-base-content/70">No members yet</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each $members as pubkey (pubkey)}
|
|
||||||
<div class="card2 card2-sm bg-alt relative">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<Profile {pubkey} {url} />
|
|
||||||
</div>
|
|
||||||
{#if canBan || canUnallow}
|
|
||||||
<div class="relative">
|
|
||||||
<Button
|
|
||||||
class="btn btn-circle btn-ghost btn-sm"
|
|
||||||
onclick={() => toggleMenu(pubkey)}>
|
|
||||||
<Icon icon={MenuDots} />
|
|
||||||
</Button>
|
|
||||||
{#if menuPubkey === pubkey}
|
|
||||||
<Popover hideOnClick onClose={closeMenu}>
|
|
||||||
<ul
|
|
||||||
transition:fly
|
|
||||||
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
|
||||||
{#if canUnallow}
|
|
||||||
<li>
|
|
||||||
<Button onclick={() => unallowMember(pubkey)}>
|
|
||||||
<Icon icon={UserMinus} />
|
|
||||||
Remove User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if canBan}
|
|
||||||
<li>
|
|
||||||
<Button class="text-error" onclick={() => banMember(pubkey)}>
|
|
||||||
<Icon icon={MinusCircle} />
|
|
||||||
Ban User
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</Popover>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={back}>
|
|
||||||
<Icon icon={AltArrowLeft} />
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
{#if $userIsAdmin}
|
|
||||||
<Button class="btn btn-primary" onclick={addMember}>
|
|
||||||
<Icon icon={AddCircle} />
|
|
||||||
Add members
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
@@ -76,6 +76,9 @@
|
|||||||
<ModalSubtitle>on {displayRelayUrl(url)}</ModalSubtitle>
|
<ModalSubtitle>on {displayRelayUrl(url)}</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if $bans.length === 0}
|
||||||
|
<div class="card2 bg-base-200 p-4 text-sm opacity-70">No banned users.</div>
|
||||||
|
{/if}
|
||||||
{#each $bans as { pubkey, reason } (pubkey)}
|
{#each $bans as { pubkey, reason } (pubkey)}
|
||||||
<div class="card2 bg-alt relative">
|
<div class="card2 bg-alt relative">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
|
||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
import Home from "@assets/icons/home.svg?dataurl"
|
||||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||||
@@ -29,12 +29,10 @@
|
|||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||||
import SpaceDetail from "@app/components/SpaceDetail.svelte"
|
|
||||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||||
import SpaceExit from "@app/components/SpaceExit.svelte"
|
import SpaceExit from "@app/components/SpaceExit.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import SpaceMembers from "@app/components/SpaceMembers.svelte"
|
|
||||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||||
@@ -42,7 +40,7 @@
|
|||||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||||
import {ENABLE_ZAPS} from "@app/env"
|
import {ENABLE_ZAPS} from "@app/env"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
import {deriveSpaceMembers, deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members"
|
||||||
import {
|
import {
|
||||||
deriveUserRooms,
|
deriveUserRooms,
|
||||||
deriveOtherRooms,
|
deriveOtherRooms,
|
||||||
@@ -70,7 +68,6 @@
|
|||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
const members = deriveSpaceMembers(url)
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||||
const actionItems = deriveSpaceActionItems(url)
|
const actionItems = deriveSpaceActionItems(url)
|
||||||
|
|
||||||
@@ -97,10 +94,6 @@
|
|||||||
showMenu = !showMenu
|
showMenu = !showMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDetail = () => pushModal(SpaceDetail, {url})
|
|
||||||
|
|
||||||
const showMembers = () => pushModal(SpaceMembers, {url})
|
|
||||||
|
|
||||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||||
|
|
||||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||||
@@ -164,22 +157,6 @@
|
|||||||
Create Invite
|
Create Invite
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Button onclick={showDetail}>
|
|
||||||
<Icon icon={RemoteControllerMinimalistic} />
|
|
||||||
Space Information
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Button onclick={showMembers}>
|
|
||||||
<Icon icon={UserRounded} />
|
|
||||||
{#if $members === undefined}
|
|
||||||
View Members
|
|
||||||
{:else}
|
|
||||||
View Members ({$members.length})
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
{#if $userIsAdmin}
|
{#if $userIsAdmin}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={showActionItems}>
|
<Button onclick={showActionItems}>
|
||||||
@@ -230,6 +207,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
|
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
|
||||||
|
<SecondaryNavItem href={makeSpacePath(url, "about")}>
|
||||||
|
<Icon icon={Home} /> Space Details
|
||||||
|
</SecondaryNavItem>
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
|
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
|
||||||
<Icon icon={History} /> Recent Activity
|
<Icon icon={History} /> Recent Activity
|
||||||
@@ -311,8 +291,8 @@
|
|||||||
<div
|
<div
|
||||||
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+0.25rem)] md:pb-2 z-nav">
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
<Link href={makeSpacePath("about")} class="btn btn-neutral btn-sm h-10">
|
||||||
<SocketStatusIndicator {url} />
|
<SocketStatusIndicator {url} />
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import RoleCreate from "@app/components/RoleCreate.svelte"
|
||||||
|
import RoleEdit from "@app/components/RoleEdit.svelte"
|
||||||
|
import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
|
||||||
|
import RoleItem from "@app/components/RoleItem.svelte"
|
||||||
|
import {deriveSpaceRoles, deleteRole, type SpaceRole} from "@app/members"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url}: Props = $props()
|
||||||
|
|
||||||
|
const roles = deriveSpaceRoles(url)
|
||||||
|
|
||||||
|
let menuRoleId = $state<string | undefined>()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
|
||||||
|
const closeMenu = () => (menuRoleId = undefined)
|
||||||
|
|
||||||
|
const createRole = () => pushModal(RoleCreate, {url})
|
||||||
|
|
||||||
|
const editRole = (role: SpaceRole) => {
|
||||||
|
menuRoleId = undefined
|
||||||
|
pushModal(RoleEdit, {url, role})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMembers = (role: SpaceRole) => {
|
||||||
|
menuRoleId = undefined
|
||||||
|
pushModal(RoleAddMembers, {url, role})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (role: SpaceRole) => {
|
||||||
|
menuRoleId = undefined
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Delete Role",
|
||||||
|
message: `Delete the "${role.label}" role? Members will keep their space membership.`,
|
||||||
|
confirm: async () => {
|
||||||
|
const error = await deleteRole(url, role.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: error})
|
||||||
|
} else {
|
||||||
|
pushToast({message: "Role deleted!"})
|
||||||
|
back()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Manage Roles</ModalTitle>
|
||||||
|
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
{#if $roles.length === 0}
|
||||||
|
<div class="card2 bg-base-200 p-4 text-sm opacity-70">
|
||||||
|
No roles yet. Create one to start organizing members.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each $roles as role (role.id)}
|
||||||
|
<div class="card2 card2-sm bg-alt flex justify-between gap-2">
|
||||||
|
<RoleItem {role} />
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<Button
|
||||||
|
class="btn btn-square btn-ghost btn-sm"
|
||||||
|
onclick={() => (menuRoleId = menuRoleId === role.id ? undefined : role.id)}>
|
||||||
|
<Icon icon={MenuDots} />
|
||||||
|
</Button>
|
||||||
|
{#if menuRoleId === role.id}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
<li>
|
||||||
|
<Button onclick={() => addMembers(role)}>
|
||||||
|
<Icon icon={AddCircle} />
|
||||||
|
Add members
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={() => editRole(role)}>
|
||||||
|
<Icon icon={Pen} />
|
||||||
|
Edit role
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button class="text-error" onclick={() => confirmDelete(role)}>
|
||||||
|
<Icon icon={TrashBin} />
|
||||||
|
Delete role
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={back}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" onclick={createRole}>
|
||||||
|
<Icon icon={AddCircle} />
|
||||||
|
Create Role
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -26,6 +26,12 @@ export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
|||||||
...extra,
|
...extra,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const makeDeleteFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||||
|
kinds: [DELETE],
|
||||||
|
"#k": kinds.map(String),
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
|
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
|
||||||
|
|
||||||
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ROOM_MEMBERS,
|
ROOM_MEMBERS,
|
||||||
ROOM_REMOVE_MEMBER,
|
ROOM_REMOVE_MEMBER,
|
||||||
getPubkeyTagValues,
|
getPubkeyTagValues,
|
||||||
|
getTags,
|
||||||
getTagValue,
|
getTagValue,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
sortEventsAsc,
|
sortEventsAsc,
|
||||||
@@ -23,11 +24,136 @@ import {first, memoize, sortBy, spec, uniq} from "@welshman/lib"
|
|||||||
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
|
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
|
||||||
import {get} from "svelte/store"
|
import {get} from "svelte/store"
|
||||||
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
|
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
|
||||||
|
|
||||||
export const deriveSpaceMembers = (url: string) =>
|
export const deriveSpaceMembers = (url: string) =>
|
||||||
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
|
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
|
||||||
uniq(getTagValues("member", event?.tags ?? [])),
|
uniq(getTagValues("member", event?.tags ?? [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const RELAY_ROLE = 33534
|
||||||
|
|
||||||
|
export type SpaceRole = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
color: number
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// hue is 0-255; map to 0-360deg. Saturation/lightness chosen to read on both themes.
|
||||||
|
export const roleColorHue = (color: number) => (((color % 256) + 256) % 256) * (360 / 256)
|
||||||
|
|
||||||
|
export const roleColor = (color: number) => `hsl(${roleColorHue(color)}, 70%, 50%)`
|
||||||
|
|
||||||
|
export const roleColorSoft = (color: number) => `hsl(${roleColorHue(color)}, 70%, 90%)`
|
||||||
|
|
||||||
|
export const deriveSpaceRoles = (url: string) =>
|
||||||
|
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => {
|
||||||
|
const roles: SpaceRole[] = []
|
||||||
|
|
||||||
|
for (const event of $events) {
|
||||||
|
const id = getTagValue("d", event.tags)
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
roles.push({
|
||||||
|
id,
|
||||||
|
label: getTagValue("label", event.tags) ?? "",
|
||||||
|
description: getTagValue("description", event.tags) ?? "",
|
||||||
|
color: parseInt(getTagValue("color", event.tags) ?? "0", 10) || 0,
|
||||||
|
order: parseInt(getTagValue("order", event.tags) ?? "0", 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(r => [r.order, r.label] as [number, string], roles)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map<pubkey, roleId[]> parsed from EXTRA values on ["member", pubkey, ...roleIds] tags
|
||||||
|
export const deriveSpaceMemberRoles = (url: string) =>
|
||||||
|
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) => {
|
||||||
|
const memberRoles = new Map<string, string[]>()
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
for (const tag of getTags("member", event.tags)) {
|
||||||
|
const pubkey = tag[1]
|
||||||
|
const roleIds = tag.slice(2)
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
memberRoles.set(pubkey, roleIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberRoles
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createRole = async (
|
||||||
|
url: string,
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
color: number,
|
||||||
|
order: number,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: "createrole" as ManagementMethod,
|
||||||
|
params: [id, label, description, color.toString(), order.toString()],
|
||||||
|
})
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editRole = async (
|
||||||
|
url: string,
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
color: number,
|
||||||
|
order: number,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: "editrole" as ManagementMethod,
|
||||||
|
params: [id, label, description, color.toString(), order.toString()],
|
||||||
|
})
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteRole = async (url: string, id: string): Promise<string | undefined> => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: "deleterole" as ManagementMethod,
|
||||||
|
params: [id],
|
||||||
|
})
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assignRole = async (
|
||||||
|
url: string,
|
||||||
|
pubkey: string,
|
||||||
|
roleId: string,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: "assignrole" as ManagementMethod,
|
||||||
|
params: [pubkey, roleId],
|
||||||
|
})
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unassignRole = async (
|
||||||
|
url: string,
|
||||||
|
pubkey: string,
|
||||||
|
roleId: string,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const {error} = await manageRelay(url, {
|
||||||
|
method: "unassignrole" as ManagementMethod,
|
||||||
|
params: [pubkey, roleId],
|
||||||
|
})
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
export const deriveRoomMembers = (url: string, h: string) => {
|
export const deriveRoomMembers = (url: string, h: string) => {
|
||||||
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
|
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
|
||||||
|
|
||||||
@@ -292,6 +418,47 @@ export const addSpaceMembers = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const removeSpaceMembers = async (
|
||||||
|
url: string,
|
||||||
|
pubkeys: string[],
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
pubkeys.map(pubkey =>
|
||||||
|
manageRelay(url, {
|
||||||
|
method: ManagementMethod.UnallowPubkey,
|
||||||
|
params: [pubkey],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const {error} of results) {
|
||||||
|
if (error) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const banSpaceMembers = async (
|
||||||
|
url: string,
|
||||||
|
pubkeys: string[],
|
||||||
|
reason = "",
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
pubkeys.map(pubkey =>
|
||||||
|
manageRelay(url, {
|
||||||
|
method: ManagementMethod.BanPubkey,
|
||||||
|
params: reason ? [pubkey, reason] : [pubkey],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const {error} of results) {
|
||||||
|
if (error) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const addRoomMembers = async (
|
export const addRoomMembers = async (
|
||||||
url: string,
|
url: string,
|
||||||
room: PublishedRoomMeta,
|
room: PublishedRoomMeta,
|
||||||
|
|||||||
+3
-2
@@ -53,6 +53,7 @@ import {
|
|||||||
} from "@app/groups"
|
} from "@app/groups"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {loadFeedsForPubkey} from "@app/feeds"
|
import {loadFeedsForPubkey} from "@app/feeds"
|
||||||
|
import {RELAY_ROLE} from "@app/members"
|
||||||
import {hasBlossomSupport} from "@app/uploads"
|
import {hasBlossomSupport} from "@app/uploads"
|
||||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||||
|
|
||||||
@@ -268,7 +269,7 @@ const syncUserData = () => {
|
|||||||
const syncSpace = (url: string) => {
|
const syncSpace = (url: string) => {
|
||||||
const since = ago(WEEK)
|
const since = ago(WEEK)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const relayKinds = [RELAY_MEMBERS]
|
const relayKinds = [RELAY_MEMBERS, RELAY_ROLE]
|
||||||
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
|
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
|
||||||
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
|
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
|
||||||
|
|
||||||
@@ -277,8 +278,8 @@ const syncSpace = (url: string) => {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
|
||||||
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
||||||
|
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav",
|
"mt-sai mb-sai max-h-screen w-60 min-h-0 shrink-0 flex-col gap-1 bg-base-300 z-nav border-r border-solid border-base-content/5 dark:border-base-content/10",
|
||||||
visible ? "flex" : "hidden md:flex",
|
visible ? "flex" : "hidden md:flex",
|
||||||
props.class,
|
props.class,
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -248,7 +248,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
<PageContent class="flex flex-col gap-2 p-2">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 p-4">
|
||||||
<div class="flex flex-col gap-2" bind:this={element}>
|
<div class="flex flex-col gap-2" bind:this={element}>
|
||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {tick} from "svelte"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {page} from "$app/stores"
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {displayProfileByPubkey, deriveRelay} from "@welshman/app"
|
||||||
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||||
|
import BillList from "@assets/icons/bill-list.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 CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
import SpaceDetails from "@app/components/SpaceDetails.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 relay = deriveRelay(url)
|
||||||
|
const roles = deriveSpaceRoles(url)
|
||||||
|
const owner = $derived($relay?.pubkey)
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
term = ""
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageContent class="flex flex-col gap-4 p-4">
|
||||||
|
<SpaceDetails {url} />
|
||||||
|
<div class="card2 bg-alt flex flex-col gap-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon icon={UsersGroup} />
|
||||||
|
Members
|
||||||
|
</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick={inviteMembers}>
|
||||||
|
<Icon icon={AddCircle} />
|
||||||
|
Invite people
|
||||||
|
</button>
|
||||||
|
{#if $userIsAdmin}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm btn-square"
|
||||||
|
aria-label="More options"
|
||||||
|
onclick={() => (menuOpen = !menuOpen)}>
|
||||||
|
<Icon size={4} icon={MenuDots} />
|
||||||
|
</button>
|
||||||
|
{#if menuOpen}
|
||||||
|
<Popover hideOnClick onClose={() => (menuOpen = false)}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
|
<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-2 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each visibleMembers as { pubkey, roleList } (pubkey)}
|
||||||
|
<SpaceMember {url} {pubkey} roles={roleList} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
@@ -126,9 +126,9 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:px-4">
|
||||||
{#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)}
|
{#each items as { event, dateDisplay, isFirstFutureEvent }, i (event.id)}
|
||||||
<div class={"calendar-event-" + event.id}>
|
<div class="flex flex-col gap-2 calendar-event-{event.id}">
|
||||||
{#if isFirstFutureEvent}
|
{#if isFirstFutureEvent}
|
||||||
<div class="flex items-center gap-2 p-2">
|
<div class="flex items-center gap-2 p-2">
|
||||||
<div class="h-px grow bg-primary"></div>
|
<div class="h-px grow bg-primary"></div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-3 p-2">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="card2 bg-alt col-3 z-feature">
|
<div class="card2 bg-alt col-3 z-feature">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#each items as event (event.id)}
|
{#each items as event (event.id)}
|
||||||
<div in:fly>
|
<div in:fly>
|
||||||
<ClassifiedItem {url} event={$state.snapshot(event)} />
|
<ClassifiedItem {url} event={$state.snapshot(event)} />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#each items as event (event.id)}
|
{#each items as event (event.id)}
|
||||||
<div in:fly>
|
<div in:fly>
|
||||||
<GoalItem {url} event={$state.snapshot(event)} />
|
<GoalItem {url} event={$state.snapshot(event)} />
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#each items as event (event.id)}
|
{#each items as event (event.id)}
|
||||||
<div in:fly>
|
<div in:fly>
|
||||||
<PollItem {url} event={$state.snapshot(event)} />
|
<PollItem {url} event={$state.snapshot(event)} />
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2">
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||||
|
|||||||
@@ -294,7 +294,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2" bind:element>
|
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4" bind:element>
|
||||||
{#if $recentActivity.length === 0}
|
{#if $recentActivity.length === 0}
|
||||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-4 p-2">
|
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
{#each threadFeed.boards as [h, threads] (h || "general")}
|
{#each threadFeed.boards as [h, threads] (h || "general")}
|
||||||
<ThreadBoard {url} {h} {threads} />
|
<ThreadBoard {url} {h} {threads} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageContent class="flex flex-col items-center gap-2 p-2">
|
<PageContent class="flex flex-col items-center gap-2 p-2 sm:gap-4 sm:p-4">
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div>Choose your Hosting Plan</div>
|
<div>Choose your Hosting Plan</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user