Open popover menus through MenuButton and Tippy, replace Popover with menu components, and make Tooltip a thin Tippy wrapper
This commit is contained in:
parent
34ee4fab5b
commit
fd7b50a7ec
32 changed files with 409 additions and 569 deletions
|
|
@ -187,15 +187,22 @@ behind its own loading state. Reusable confirmations get a `*Confirm` wrapper
|
|||
### Popover menus
|
||||
|
||||
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
|
||||
that hides it:
|
||||
that hides it. It shows the menu-dots icon unless given children, opens `bottom-end` unless given a
|
||||
`placement`, and takes `strayMargin` to hide once the pointer strays that many pixels from the
|
||||
popover, as the hover actions on a chat row do:
|
||||
|
||||
```svelte
|
||||
<MenuButton component={ChatMenu} aria-label="Chat options" />
|
||||
```
|
||||
|
||||
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item
|
||||
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes
|
||||
`RoomItemMenuMobile` on tap.
|
||||
`EventMenu` puts `onClick` on its `<ul>` as `onclickcapture`, so any item closes the popover, and
|
||||
each item usually pushes a modal. It has to be the capture phase: `Button` stops propagation, and
|
||||
Svelte's delegated `onclick` on the `<ul>` would never see the click. On mobile, rows push a
|
||||
`*MenuMobile` modal instead: `RoomItem` pushes `RoomItemMenuMobile` on tap.
|
||||
|
||||
`Tooltip` is `Tippy` with a string `content` and the `tooltip` theme, whose CSS lives in
|
||||
`tooltip.css`. `Tippy` appends popovers to the page's `.tippy-target`, or to the open dialog's
|
||||
`.dialog-tippy-target` so they stack above it.
|
||||
|
||||
### Toasts
|
||||
|
||||
|
|
@ -222,8 +229,8 @@ lives there:
|
|||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
|
||||
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`,
|
||||
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
||||
|
||||
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {PinboardReader} from "@welshman/domain"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
|
|
@ -59,15 +58,9 @@
|
|||
message: `Delete "${board.title() || "this shelf"}"?`,
|
||||
confirm: deleteBoard,
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon icon={Code2} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {assoc} from "@welshman/lib"
|
||||
import {publish} from "@welshman/app"
|
||||
import Check from "@assets/icons/check.svg?dataurl"
|
||||
|
|
@ -34,15 +33,9 @@
|
|||
addRelay: (url: string) => $messagingRelayLists.addUrl(url).then(publish),
|
||||
removeRelay: (url: string) => $messagingRelayLists.removeUrl(url).then(publish),
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={markAsRead}>
|
||||
<Icon size={4} icon={Check} />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import {onDestroy, onMount} from "svelte"
|
||||
import {writable} from "svelte/store"
|
||||
import cx from "classnames"
|
||||
import type {Maybe, MaybeAsync} from "@welshman/lib"
|
||||
import type {MaybeAsync} from "@welshman/lib"
|
||||
import {MESSAGE} from "@welshman/util"
|
||||
import type {EventContent} from "@welshman/util"
|
||||
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
|
||||
|
|
@ -11,8 +11,7 @@
|
|||
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||
|
|
@ -115,10 +114,6 @@
|
|||
.run()
|
||||
}
|
||||
|
||||
const showMenu = () => tippy?.show()
|
||||
|
||||
const hideMenu = () => tippy?.hide()
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading || disabled || sending) {
|
||||
return
|
||||
|
|
@ -145,7 +140,6 @@
|
|||
}
|
||||
|
||||
let sending = false
|
||||
let tippy: Maybe<TippyController> = $state()
|
||||
let content = $state(
|
||||
initialValues?.type === "text" ? initialValues.value : (draft?.get()?.content ?? ""),
|
||||
)
|
||||
|
|
@ -212,15 +206,14 @@
|
|||
{/if}
|
||||
</Button>
|
||||
{#if url}
|
||||
<Button disabled={$uploading} onclick={showMenu} class={buttonClass}>
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
component={ComposeMenu}
|
||||
props={{url, h, onClick: hideMenu}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Icon icon={WidgetAdd} />
|
||||
</Tippy>
|
||||
</Button>
|
||||
<MenuButton
|
||||
component={ComposeMenu}
|
||||
componentProps={{url, h}}
|
||||
placement="top"
|
||||
disabled={$uploading}
|
||||
class={buttonClass}>
|
||||
<Icon icon={WidgetAdd} />
|
||||
</MenuButton>
|
||||
{/if}
|
||||
<EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
|
|
@ -35,15 +34,9 @@
|
|||
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
|
||||
|
||||
const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true}))
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={createGoal}>
|
||||
<Icon size={4} icon={StarFallMinimalistic} />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import EventMenu from "@app/components/EventMenu.svelte"
|
||||
import EventReactButtons from "@app/components/EventReactButtons.svelte"
|
||||
|
||||
|
|
@ -19,25 +14,16 @@
|
|||
}
|
||||
|
||||
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||
|
||||
const showPopover = () => tippy?.show()
|
||||
|
||||
const hidePopover = () => tippy?.hide()
|
||||
|
||||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<!-- The compact segmented control a card footer gets. EventActionBar is the full-width
|
||||
version a detail page gets. -->
|
||||
<div class="items-center join">
|
||||
<EventReactButtons {url} {event} {hideZap} class="button button-neutral button-xs join-item" />
|
||||
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
component={EventMenu}
|
||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Icon icon={MenuDots} size={4} />
|
||||
</Tippy>
|
||||
</Button>
|
||||
<MenuButton
|
||||
component={EventMenu}
|
||||
componentProps={{url, noun, event, customActions}}
|
||||
placement="top"
|
||||
iconSize={4}
|
||||
class="flex join-item button button-neutral button-xs" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT} from "@welshman/util"
|
||||
|
|
@ -44,15 +43,9 @@
|
|||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
|
||||
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon size={4} icon={Code2} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {PinReader} from "@welshman/domain"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
|
|
@ -53,15 +52,9 @@
|
|||
message: "Delete this link?",
|
||||
confirm: deletePin,
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={showInfo}>
|
||||
<Icon icon={Code2} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -10,15 +9,9 @@
|
|||
}
|
||||
|
||||
const {onDelete, onClick}: Props = $props()
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={onDelete}>
|
||||
<Icon icon={TrashBin2} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {Snippet} from "svelte"
|
||||
import {removeUndefined} from "@welshman/lib"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
|
|
@ -25,15 +24,9 @@
|
|||
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
|
||||
|
||||
const showShare = () => pushModal(ProfileQrCode, {pubkey})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={showShare}>
|
||||
<Icon size={4} icon={ShareCircle} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {Report} from "@welshman/domain"
|
||||
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
||||
|
|
@ -87,15 +86,9 @@
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if event.pubkey === $user.pubkey}
|
||||
<li>
|
||||
<Button onclick={deleteReport}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {MembershipStatus, publish} from "@welshman/app"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
|
|
@ -70,14 +69,9 @@
|
|||
})
|
||||
|
||||
let loading = $state(false)
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if $userIsAdmin}
|
||||
<li>
|
||||
<Button onclick={startEdit}>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import {deriveDisplaysByPubkey} from "@app/social"
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import RoomItemZapButton from "@app/components/RoomItemZapButton.svelte"
|
||||
import RoomItemEmojiButton from "@app/components/RoomItemEmojiButton.svelte"
|
||||
import RoomItemMenuButton from "@app/components/RoomItemMenuButton.svelte"
|
||||
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
|
||||
import RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import {events, profiles, reader, thunks, user} from "@app/core"
|
||||
|
|
@ -189,7 +190,14 @@
|
|||
<Icon icon={Pen} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
<RoomItemMenuButton {url} {event} />
|
||||
<MenuButton
|
||||
component={RoomItemMenu}
|
||||
componentProps={{url, event}}
|
||||
placement="top"
|
||||
strayMargin={50}
|
||||
iconSize={4}
|
||||
aria-label="More options"
|
||||
class="button button-xs button-neutral join-item" />
|
||||
</div>
|
||||
{/if}
|
||||
</TapTarget>
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {between} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||
import RoomItemMenu from "@app/components/RoomItemMenu.svelte"
|
||||
|
||||
const {url, event} = $props()
|
||||
|
||||
const open = () => tippy?.show()
|
||||
|
||||
const onClick = () => tippy?.hide()
|
||||
|
||||
const onMouseMove = ({clientX, clientY}: MouseEvent) => {
|
||||
const rect = tippy?.rect()
|
||||
|
||||
if (rect) {
|
||||
const {x, y, width, height} = rect
|
||||
|
||||
if (!between([x, x + width], clientX) || !between([y - 50, y + height + 50], clientY)) {
|
||||
tippy!.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
|
||||
|
||||
<Button aria-label="More options" onclick={open} class="button button-xs button-neutral join-item">
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
component={RoomItemMenu}
|
||||
props={{url, event, onClick}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Icon icon={MenuDots} size={4} />
|
||||
</Tippy>
|
||||
</Button>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -33,15 +32,9 @@
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button class="text-error" onclick={removeMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
|
|
|
|||
54
src/app/components/SpaceDirectoryMenu.svelte
Normal file
54
src/app/components/SpaceDirectoryMenu.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import SpaceAdmins from "@app/components/SpaceAdmins.svelte"
|
||||
import SpaceRoles from "@app/components/SpaceRoles.svelte"
|
||||
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
|
||||
import {deriveSpacePermissions} from "@app/management"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const {url, onClick}: Props = $props()
|
||||
|
||||
const permissions = deriveSpacePermissions(url)
|
||||
|
||||
const manageRoles = () => pushModal(SpaceRoles, {url})
|
||||
|
||||
const spaceAdmins = () => pushModal(SpaceAdmins, {url})
|
||||
|
||||
const bannedMembers = () => pushModal(SpaceMembersBanned, {url})
|
||||
</script>
|
||||
|
||||
<ul class="menu w-48 gap-1 rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if $permissions.manageRoles}
|
||||
<li>
|
||||
<Button onclick={manageRoles}>
|
||||
<Icon icon={UsersGroup} />
|
||||
Manage Roles
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $permissions.listAdmins}
|
||||
<li>
|
||||
<Button onclick={spaceAdmins}>
|
||||
<Icon icon={ShieldUser} />
|
||||
Admins
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $permissions.listBans}
|
||||
<li>
|
||||
<Button onclick={bannedMembers}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Banned Members
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import Restart from "@assets/icons/restart.svg?dataurl"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -39,15 +38,9 @@
|
|||
$spaceManagement.loadBannedPubkeys(url)
|
||||
}
|
||||
}
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if $permissions.unban}
|
||||
<li>
|
||||
<Button onclick={unbanMember}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
||||
|
|
@ -61,15 +60,9 @@
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if $permissions.assignRoles || $permissions.unassignRoles}
|
||||
<li>
|
||||
<Button onclick={editRoles}>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
|
||||
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
|
|
@ -32,13 +31,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu = !showMenu
|
||||
const closeMenu = () => {
|
||||
showMenu = false
|
||||
}
|
||||
|
||||
// Deferred so the click that follows the mouseup still reaches the item before the menu goes.
|
||||
const onMouseUp = () => setTimeout(closeMenu)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setTimeout(closeMenu)
|
||||
}
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onmouseup={showMenu ? onMouseUp : undefined}
|
||||
onkeydown={showMenu ? onKeyDown : undefined} />
|
||||
|
||||
<Button
|
||||
class={cx(
|
||||
mobile
|
||||
|
|
@ -64,9 +76,9 @@
|
|||
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
||||
</Button>
|
||||
{#if showMenu && !mobile}
|
||||
<Popover hideOnClick onClose={toggleMenu}>
|
||||
<div class="relative w-full">
|
||||
<ul transition:fly class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
|
||||
<SpaceMenuActions {url} />
|
||||
</ul>
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import type {RelayRoleReader} from "@welshman/domain"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
|
|
@ -47,15 +46,9 @@
|
|||
message: `Delete the "${role.label()}" role? Members will keep their space membership.`,
|
||||
confirm: deleteRole,
|
||||
})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
ul.addEventListener("click", onClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
|
||||
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" onclickcapture={onClick}>
|
||||
{#if $permissions.assignRoles}
|
||||
<li>
|
||||
<Button onclick={addMembers}>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,25 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {spec} from "@welshman/lib"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Power from "@assets/icons/power.svg?dataurl"
|
||||
import Play from "@assets/icons/play.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
|
||||
import ShieldWarning from "@assets/icons/shield-warning.svg?dataurl"
|
||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||
import Refresh from "@assets/icons/refresh.svg?dataurl"
|
||||
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||
import Database from "@assets/icons/database.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import {ucFirst} from "@lib/util"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||
import RelayDetailMenu from "@app/components/hosting/RelayDetailMenu.svelte"
|
||||
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
|
||||
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
|
||||
import PlanModal from "@app/components/hosting/PlanModal.svelte"
|
||||
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
||||
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||
import {pushModal, clearModals} from "@app/modal"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
import {
|
||||
RELAY_FLAG_DEFAULTS,
|
||||
|
|
@ -45,8 +34,6 @@
|
|||
isPaidPlan,
|
||||
updateRelay,
|
||||
getRelay,
|
||||
deactivateRelay,
|
||||
reactivateRelay,
|
||||
deriveRelayMembers,
|
||||
type HostedRelay,
|
||||
type RelayFlag,
|
||||
|
|
@ -63,7 +50,6 @@
|
|||
const members = deriveRelayMembers(relay.id)
|
||||
|
||||
const host = $derived(relayHost(relay))
|
||||
const relayLabel = $derived(relay.info_name || relay.subdomain)
|
||||
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
|
||||
const recordTarget = $derived(canonicalRelayHost(relay))
|
||||
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record.
|
||||
|
|
@ -85,48 +71,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
|
||||
|
||||
const openEdit = () => {
|
||||
const previous = relay
|
||||
const initialValues: RelayFormValues = {
|
||||
info_name: relay.info_name,
|
||||
subdomain: relay.subdomain,
|
||||
info_icon: relay.info_icon,
|
||||
info_description: relay.info_description,
|
||||
plan_id: relay.plan_id,
|
||||
}
|
||||
|
||||
pushModal(RelayForm, {
|
||||
mode: "edit",
|
||||
initialValues,
|
||||
zooidDomain: relay.zooid_domain,
|
||||
onSubmit: async (values: RelayFormValues) => {
|
||||
const updated = await updateRelay(previous.id, values)
|
||||
|
||||
clearModals()
|
||||
|
||||
await followHostedRelay(previous, updated)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openDataTransfer = () => {
|
||||
pushModal(DataTransferModal, {relay})
|
||||
}
|
||||
|
||||
const openCustomDomain = () => {
|
||||
pushModal(CustomDomainModal, {relay})
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
showMenu = true
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
showMenu = false
|
||||
}
|
||||
|
||||
const copyTarget = () => clip(recordTarget)
|
||||
|
||||
// Verification runs in a backend poller; reload to pick up the result.
|
||||
|
|
@ -150,48 +98,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
const requestDeactivate = () => {
|
||||
pushModal(Confirm, {
|
||||
title: "Deactivate relay?",
|
||||
subtitle: `${relayLabel} will be taken offline immediately.`,
|
||||
message:
|
||||
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await deactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: hostingErrorMessage(e, "Failed to deactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const requestReactivate = () => {
|
||||
pushModal(Confirm, {
|
||||
title: "Reactivate relay?",
|
||||
subtitle: `${relayLabel} will come back online.`,
|
||||
message: "The relay will start accepting connections again.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await reactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: hostingErrorMessage(e, "Failed to reactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
|
|
@ -216,7 +122,7 @@
|
|||
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-xl font-bold">{relayLabel}</h2>
|
||||
<h2 class="text-xl font-bold">{relay.info_name || relay.subdomain}</h2>
|
||||
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
|
||||
{ucFirst(relay.status.replace(/_/g, " "))}
|
||||
</Badge>
|
||||
|
|
@ -233,53 +139,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<Button
|
||||
class="button button-neutral button-circle"
|
||||
aria-label="Relay actions"
|
||||
onclick={openMenu}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if showMenu}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-2xl border border-line bg-surface p-2 shadow-xl">
|
||||
<li>
|
||||
<Button onclick={openEdit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit details
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={openPlan}>
|
||||
<Icon icon={BillList} />
|
||||
Change plan
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={openDataTransfer}>
|
||||
<Icon icon={Database} />
|
||||
Import / export data
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
{#if relay.status === "active"}
|
||||
<Button class="text-error" onclick={requestDeactivate}>
|
||||
<Icon icon={Power} />
|
||||
Deactivate
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="text-primary" onclick={requestReactivate}>
|
||||
<Icon icon={Play} />
|
||||
Reactivate
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<MenuButton
|
||||
class="button button-neutral button-circle"
|
||||
aria-label="Relay actions"
|
||||
component={RelayDetailMenu}
|
||||
componentProps={{relay}} />
|
||||
</div>
|
||||
|
||||
{#if relay.sync_error}
|
||||
|
|
|
|||
142
src/app/components/hosting/RelayDetailMenu.svelte
Normal file
142
src/app/components/hosting/RelayDetailMenu.svelte
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<script lang="ts">
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import Power from "@assets/icons/power.svg?dataurl"
|
||||
import Play from "@assets/icons/play.svg?dataurl"
|
||||
import BillList from "@assets/icons/bill-list.svg?dataurl"
|
||||
import Database from "@assets/icons/database.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
|
||||
import PlanModal from "@app/components/hosting/PlanModal.svelte"
|
||||
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
||||
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||
import {pushModal, clearModals} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {
|
||||
followHostedRelay,
|
||||
hosting,
|
||||
hostingErrorMessage,
|
||||
updateRelay,
|
||||
getRelay,
|
||||
deactivateRelay,
|
||||
reactivateRelay,
|
||||
type HostedRelay,
|
||||
} from "@app/hosting"
|
||||
|
||||
type Props = {
|
||||
relay: HostedRelay
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const {relay, onClick}: Props = $props()
|
||||
|
||||
const relayLabel = $derived(relay.info_name || relay.subdomain)
|
||||
|
||||
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
|
||||
|
||||
const openEdit = () => {
|
||||
const previous = relay
|
||||
const initialValues: RelayFormValues = {
|
||||
info_name: relay.info_name,
|
||||
subdomain: relay.subdomain,
|
||||
info_icon: relay.info_icon,
|
||||
info_description: relay.info_description,
|
||||
plan_id: relay.plan_id,
|
||||
}
|
||||
|
||||
pushModal(RelayForm, {
|
||||
mode: "edit",
|
||||
initialValues,
|
||||
zooidDomain: relay.zooid_domain,
|
||||
onSubmit: async (values: RelayFormValues) => {
|
||||
const updated = await updateRelay(previous.id, values)
|
||||
|
||||
clearModals()
|
||||
|
||||
await followHostedRelay(previous, updated)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openDataTransfer = () => {
|
||||
pushModal(DataTransferModal, {relay})
|
||||
}
|
||||
|
||||
const requestDeactivate = () => {
|
||||
pushModal(Confirm, {
|
||||
title: "Deactivate relay?",
|
||||
subtitle: `${relayLabel} will be taken offline immediately.`,
|
||||
message:
|
||||
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await deactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: hostingErrorMessage(e, "Failed to deactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const requestReactivate = () => {
|
||||
pushModal(Confirm, {
|
||||
title: "Reactivate relay?",
|
||||
subtitle: `${relayLabel} will come back online.`,
|
||||
message: "The relay will start accepting connections again.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await reactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: hostingErrorMessage(e, "Failed to reactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul
|
||||
class="menu w-52 gap-1 rounded-2xl border border-line bg-surface p-2 shadow-xl"
|
||||
onclickcapture={onClick}>
|
||||
<li>
|
||||
<Button onclick={openEdit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit details
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={openPlan}>
|
||||
<Icon icon={BillList} />
|
||||
Change plan
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={openDataTransfer}>
|
||||
<Icon icon={Database} />
|
||||
Import / export data
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
{#if relay.status === "active"}
|
||||
<Button class="text-error" onclick={requestDeactivate}>
|
||||
<Icon icon={Power} />
|
||||
Deactivate
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="text-primary" onclick={requestReactivate}>
|
||||
<Icon icon={Play} />
|
||||
Reactivate
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {between, throttle} from "@welshman/lib"
|
||||
import type {Props as TippyProps} from "tippy.js"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||
|
||||
type Props = {
|
||||
tippyParams?: Record<string, unknown>
|
||||
tippyParams?: Partial<TippyProps>
|
||||
onEmoji: (emoji: string) => void
|
||||
allowKaomoji?: boolean
|
||||
class?: string
|
||||
|
|
@ -32,28 +32,15 @@
|
|||
tippy?.hide()
|
||||
}
|
||||
|
||||
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
|
||||
const rect = tippy?.rect()
|
||||
|
||||
if (rect) {
|
||||
const {x, y, width, height} = rect
|
||||
|
||||
if (!between([x, x + width], clientX) || !between([y - 100, y + height + 100], clientY)) {
|
||||
tippy!.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
|
||||
|
||||
<Button onclick={open} class={className} {...restProps}>
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
class="flex"
|
||||
component={EmojiPicker}
|
||||
strayMargin={allowKaomoji ? undefined : 100}
|
||||
props={{onClick, allowKaomoji}}
|
||||
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {between, throttle} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {isMobile} from "@lib/html"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -32,22 +31,13 @@
|
|||
close()
|
||||
}
|
||||
|
||||
const onMouseMove = throttle(300, ({clientX}: MouseEvent) => {
|
||||
const rect = tippy?.rect()
|
||||
|
||||
if (rect && !between([rect.x - 50, rect.x + rect.width + 50], clientX)) {
|
||||
tippy!.hide()
|
||||
}
|
||||
})
|
||||
|
||||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<svelte:document onmousemove={onMouseMove} />
|
||||
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
component={IconPickerPopover}
|
||||
strayMargin={50}
|
||||
props={{onSelect: onClick}}
|
||||
params={{trigger: "manual", interactive: true, placement: "top-end"}}>
|
||||
<Button onclick={open} class={props.class}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type {Component} from "svelte"
|
||||
import type {Component, Snippet} from "svelte"
|
||||
import type {Placement} from "tippy.js"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -10,16 +11,24 @@
|
|||
type Props = {
|
||||
component: Component<any>
|
||||
componentProps?: Record<string, any>
|
||||
placement?: Placement
|
||||
strayMargin?: number
|
||||
class?: string
|
||||
iconSize?: number
|
||||
disabled?: boolean
|
||||
children?: Snippet
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
const {
|
||||
component,
|
||||
componentProps = {},
|
||||
placement = "bottom-end",
|
||||
strayMargin,
|
||||
class: className = "button button-ghost button-sm button-circle",
|
||||
iconSize,
|
||||
disabled,
|
||||
children,
|
||||
"aria-label": ariaLabel,
|
||||
}: Props = $props()
|
||||
|
||||
|
|
@ -30,12 +39,17 @@
|
|||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<Button class={className} aria-label={ariaLabel} onclick={showPopover}>
|
||||
<Button class={className} aria-label={ariaLabel} {disabled} onclick={showPopover}>
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
{component}
|
||||
{strayMargin}
|
||||
props={{...componentProps, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>
|
||||
<Icon icon={MenuDots} size={iconSize} />
|
||||
params={{trigger: "manual", interactive: true, placement}}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<Icon icon={MenuDots} size={iconSize} />
|
||||
{/if}
|
||||
</Tippy>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {fly} from "@lib/transition"
|
||||
|
||||
interface Props {
|
||||
onClose: any
|
||||
hideOnClick?: boolean
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
const {onClose, hideOnClick = false, children}: Props = $props()
|
||||
|
||||
const onMouseUp = (e: any) => {
|
||||
if (hideOnClick || !element?.contains(e.target)) {
|
||||
setTimeout(onClose)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: any) => {
|
||||
if (e.key === "Escape") {
|
||||
setTimeout(onClose)
|
||||
}
|
||||
}
|
||||
|
||||
let element: HTMLElement | undefined = $state()
|
||||
</script>
|
||||
|
||||
<svelte:window onmouseup={onMouseUp} onkeydown={onKeyDown} />
|
||||
|
||||
<div class="relative w-full" bind:this={element}>
|
||||
<div transition:fly|local class="absolute z-popover w-full">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -6,27 +6,56 @@
|
|||
hide: () => void
|
||||
toggle: () => void
|
||||
visible: boolean
|
||||
rect: () => Maybe<DOMRect>
|
||||
content: Maybe<Content>
|
||||
}
|
||||
|
||||
// Cached because a couple of popovers per chat row runs this thousands of times.
|
||||
let tippyTarget: Maybe<Element>
|
||||
|
||||
const getTippyTarget = (trigger: Element) => {
|
||||
const dialogTarget = trigger.closest(".dialog-overlay")?.querySelector(".dialog-tippy-target")
|
||||
|
||||
if (dialogTarget) {
|
||||
return dialogTarget
|
||||
}
|
||||
|
||||
if (!tippyTarget?.isConnected) {
|
||||
tippyTarget = document.querySelector(".tippy-target")!
|
||||
}
|
||||
|
||||
return tippyTarget
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import "tippy.js/animations/shift-away.css"
|
||||
|
||||
import tippy from "tippy.js"
|
||||
import type {Instance} from "tippy.js"
|
||||
import type {Instance, Props as TippyProps} from "tippy.js"
|
||||
import {onMount, mount, unmount} from "svelte"
|
||||
import {getTippyTarget, isMobile} from "@lib/html"
|
||||
import type {Component, Snippet} from "svelte"
|
||||
import {between, throttle} from "@welshman/lib"
|
||||
import {isMobile} from "@lib/html"
|
||||
|
||||
type Props = {
|
||||
component?: Component<any>
|
||||
children?: Snippet
|
||||
props?: Record<string, any>
|
||||
params?: Partial<TippyProps>
|
||||
strayMargin?: number
|
||||
controller?: TippyController
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
component,
|
||||
children = undefined,
|
||||
children,
|
||||
props = {},
|
||||
params = {},
|
||||
strayMargin,
|
||||
controller = $bindable(),
|
||||
...restProps
|
||||
} = $props()
|
||||
class: className,
|
||||
}: Props = $props()
|
||||
|
||||
const target = document.createElement("div")
|
||||
|
||||
|
|
@ -56,15 +85,24 @@
|
|||
: element.closest<HTMLElement>("button, [href], input, [tabindex]") ||
|
||||
element.querySelector<HTMLElement>("button, [href], input, [tabindex]") ||
|
||||
undefined
|
||||
content ??= mount(component, {target, props: mountedProps})
|
||||
|
||||
if (component) {
|
||||
content ??= mount(component, {target, props: mountedProps})
|
||||
}
|
||||
|
||||
visible = true
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
|
||||
if (strayMargin) {
|
||||
document.addEventListener("mousemove", onMouseMove)
|
||||
}
|
||||
|
||||
return params.onShow?.(instance)
|
||||
},
|
||||
onHidden: (instance: Instance) => {
|
||||
visible = false
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
|
||||
return params.onHidden?.(instance)
|
||||
},
|
||||
|
|
@ -85,6 +123,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
|
||||
const rect = popover?.popper.getBoundingClientRect()
|
||||
|
||||
if (rect && strayMargin) {
|
||||
const {x, y, width, height} = rect
|
||||
const inX = between([x - strayMargin, x + width + strayMargin], clientX)
|
||||
const inY = between([y - strayMargin, y + height + strayMargin], clientY)
|
||||
|
||||
if (!inX || !inY) {
|
||||
popover?.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
controller = {
|
||||
show: () => create().show(),
|
||||
hide: () => popover?.hide(),
|
||||
|
|
@ -95,7 +147,6 @@
|
|||
create().show()
|
||||
}
|
||||
},
|
||||
rect: () => popover?.popper.getBoundingClientRect(),
|
||||
get visible() {
|
||||
return visible
|
||||
},
|
||||
|
|
@ -115,6 +166,7 @@
|
|||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
popover?.destroy()
|
||||
|
||||
if (content) {
|
||||
|
|
@ -124,6 +176,6 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class={restProps.class}>
|
||||
<div bind:this={element} class={className}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,65 +1,16 @@
|
|||
<style>
|
||||
:global(.tippy-box[data-theme~="tooltip"]) {
|
||||
background-color: var(--content);
|
||||
color: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:global(.tippy-box[data-theme~="tooltip"][data-placement^="top"] > .tippy-arrow::before) {
|
||||
border-top-color: var(--content);
|
||||
}
|
||||
|
||||
:global(.tippy-box[data-theme~="tooltip"][data-placement^="bottom"] > .tippy-arrow::before) {
|
||||
border-bottom-color: var(--content);
|
||||
}
|
||||
|
||||
:global(.tippy-box[data-theme~="tooltip"][data-placement^="left"] > .tippy-arrow::before) {
|
||||
border-left-color: var(--content);
|
||||
}
|
||||
|
||||
:global(.tippy-box[data-theme~="tooltip"][data-placement^="right"] > .tippy-arrow::before) {
|
||||
border-right-color: var(--content);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import "tippy.js/animations/shift-away.css"
|
||||
import type {Snippet} from "svelte"
|
||||
import Tippy from "@lib/components/Tippy.svelte"
|
||||
|
||||
import tippy from "tippy.js"
|
||||
import {onMount} from "svelte"
|
||||
import {getTippyTarget, isMobile} from "@lib/html"
|
||||
type Props = {
|
||||
content: string
|
||||
children: Snippet
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
content,
|
||||
arrow = true,
|
||||
interactive = false,
|
||||
children = undefined,
|
||||
instance = $bindable(),
|
||||
...restProps
|
||||
} = $props()
|
||||
|
||||
let element: Element
|
||||
|
||||
onMount(() => {
|
||||
instance = tippy(element, {
|
||||
content,
|
||||
arrow,
|
||||
interactive,
|
||||
animation: "shift-away",
|
||||
theme: "tooltip",
|
||||
appendTo: getTippyTarget(element),
|
||||
trigger: isMobile ? "click" : "mouseenter focus",
|
||||
})
|
||||
|
||||
return () => {
|
||||
instance?.destroy()
|
||||
}
|
||||
})
|
||||
const {content, children, class: className}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class={restProps.class}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<Tippy class={className} params={{content, theme: "tooltip"}}>
|
||||
{@render children()}
|
||||
</Tippy>
|
||||
|
|
|
|||
|
|
@ -419,12 +419,6 @@ body.keyboard-open .room {
|
|||
padding: 0.7rem 1rem !important;
|
||||
}
|
||||
|
||||
/* tippy popover */
|
||||
.tippy-box {
|
||||
@apply rounded-3xl;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* emoji picker — the .emoji-picker frame around it supplies the border */
|
||||
emoji-picker {
|
||||
--background: var(--surface);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* the host tippy renders its popovers into; see getTippyTarget in lib/html.ts */
|
||||
/* the host tippy renders its popovers into; see getTippyTarget in Tippy.svelte */
|
||||
.tippy-target {
|
||||
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||
}
|
||||
|
|
@ -50,4 +50,34 @@
|
|||
.tippy-target > * {
|
||||
@apply pointer-events-auto;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
@apply rounded-3xl;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"] {
|
||||
background-color: var(--content);
|
||||
color: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"][data-placement^="top"] > .tippy-arrow::before {
|
||||
border-top-color: var(--content);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"] > .tippy-arrow::before {
|
||||
border-bottom-color: var(--content);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"][data-placement^="left"] > .tippy-arrow::before {
|
||||
border-left-color: var(--content);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"][data-placement^="right"] > .tippy-arrow::before {
|
||||
border-right-color: var(--content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {readable} from "svelte/store"
|
||||
import {sleep, randomId} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
export {preventDefault, stopPropagation} from "svelte/legacy"
|
||||
|
||||
|
|
@ -121,23 +120,6 @@ export const createScroller = ({
|
|||
|
||||
export const isMobile = "ontouchstart" in document.documentElement
|
||||
|
||||
// Cached because a couple of popovers per chat row runs this thousands of times.
|
||||
let tippyTarget: Maybe<Element>
|
||||
|
||||
export const getTippyTarget = (trigger?: Element) => {
|
||||
const dialogTarget = trigger?.closest(".dialog-overlay")?.querySelector(".dialog-tippy-target")
|
||||
|
||||
if (dialogTarget) {
|
||||
return dialogTarget
|
||||
}
|
||||
|
||||
if (!tippyTarget?.isConnected) {
|
||||
tippyTarget = document.querySelector(".tippy-target")!
|
||||
}
|
||||
|
||||
return tippyTarget
|
||||
}
|
||||
|
||||
export const downloadText = async (filename: string, text: string) => {
|
||||
// The <a download> blob trick is a no-op in native WebViews, so write the file and share it.
|
||||
if (["android", "ios"].includes(Capacitor.getPlatform())) {
|
||||
|
|
|
|||
|
|
@ -3,21 +3,15 @@
|
|||
import {removeUndefined, sortBy} from "@welshman/lib"
|
||||
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 ShieldUser from "@assets/icons/shield-user.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 MenuButton from "@lib/components/MenuButton.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 SpaceAdmins from "@app/components/SpaceAdmins.svelte"
|
||||
import SpaceRoles from "@app/components/SpaceRoles.svelte"
|
||||
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
|
||||
import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte"
|
||||
import {deriveSpacePermissions} from "@app/management"
|
||||
import {deriveSpaceMemberRoles} from "@app/roles"
|
||||
import {relayMemberLists, relayRoles} from "@app/core"
|
||||
|
|
@ -47,27 +41,7 @@
|
|||
}))
|
||||
})
|
||||
|
||||
let menuOpen = $state(false)
|
||||
|
||||
const inviteMembers = () => {
|
||||
menuOpen = false
|
||||
pushModal(SpaceInvite, {url})
|
||||
}
|
||||
|
||||
const manageRoles = () => {
|
||||
menuOpen = false
|
||||
pushModal(SpaceRoles, {url})
|
||||
}
|
||||
|
||||
const spaceAdmins = () => {
|
||||
menuOpen = false
|
||||
pushModal(SpaceAdmins, {url})
|
||||
}
|
||||
|
||||
const bannedMembers = () => {
|
||||
menuOpen = false
|
||||
pushModal(SpaceMembersBanned, {url})
|
||||
}
|
||||
const inviteMembers = () => pushModal(SpaceInvite, {url})
|
||||
|
||||
// A role section matches on its own name, so it survives a term its members don't match.
|
||||
let term = $state("")
|
||||
|
|
@ -112,46 +86,12 @@
|
|||
Invite people
|
||||
</Button>
|
||||
{#if $permissions.directoryMenu}
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="button button-neutral button-sm button-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 bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
|
||||
{#if $permissions.manageRoles}
|
||||
<li>
|
||||
<Button onclick={manageRoles}>
|
||||
<Icon icon={UsersGroup} />
|
||||
Manage Roles
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $permissions.listAdmins}
|
||||
<li>
|
||||
<Button onclick={spaceAdmins}>
|
||||
<Icon icon={ShieldUser} />
|
||||
Admins
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $permissions.listBans}
|
||||
<li>
|
||||
<Button onclick={bannedMembers}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Banned Members
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<MenuButton
|
||||
class="button button-neutral button-sm button-square"
|
||||
aria-label="More options"
|
||||
iconSize={4}
|
||||
component={SpaceDirectoryMenu}
|
||||
componentProps={{url}} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
|
|
|||
Loading…
Reference in a new issue