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
|
### Popover menus
|
||||||
|
|
||||||
`MenuButton` renders a `Tippy` popover around the component you pass, adding an `onClick` prop
|
`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
|
```svelte
|
||||||
<MenuButton component={ChatMenu} aria-label="Chat options" />
|
<MenuButton component={ChatMenu} aria-label="Chat options" />
|
||||||
```
|
```
|
||||||
|
|
||||||
`EventMenu` attaches `onClick` to its `<ul>`, so any item closes the popover, and each item
|
`EventMenu` puts `onClick` on its `<ul>` as `onclickcapture`, so any item closes the popover, and
|
||||||
usually pushes a modal. On mobile, rows push a `*MenuMobile` modal instead: `RoomItem` pushes
|
each item usually pushes a modal. It has to be the capture phase: `Button` stops propagation, and
|
||||||
`RoomItemMenuMobile` on tap.
|
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
|
### Toasts
|
||||||
|
|
||||||
|
|
@ -222,8 +229,8 @@ lives there:
|
||||||
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
`ModalSubtitle`, `ModalFooter`, `Confirm`
|
||||||
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
- controls: `Button`, `Link`, `Field`, `FieldInline`, `Input`, `InputList`, `ToggleInput`,
|
||||||
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
`DateTimeInput`, `ImagesInput`, `IconInput`, `EmojiPicker`, `MenuButton`, `SegmentedButtons`
|
||||||
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`,
|
- display and lists: `Icon`, `Badge`, `Card`, `Divider`, `Spinner`, `Tooltip`, `Tippy`, `Cv`,
|
||||||
`Popover`, `Cv`, `VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
`VirtualList`, `Masonry`, `DragList`, `ScrollToTop`
|
||||||
- the CSS component families (`button.css`, `card.css`, …) and the theme tokens
|
- 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
|
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
|
@ -59,15 +58,9 @@
|
||||||
message: `Delete "${board.title() || "this shelf"}"?`,
|
message: `Delete "${board.title() || "this shelf"}"?`,
|
||||||
confirm: deleteBoard,
|
confirm: deleteBoard,
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={showInfo}>
|
<Button onclick={showInfo}>
|
||||||
<Icon icon={Code2} />
|
<Icon icon={Code2} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {assoc} from "@welshman/lib"
|
import {assoc} from "@welshman/lib"
|
||||||
import {publish} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import Check from "@assets/icons/check.svg?dataurl"
|
import Check from "@assets/icons/check.svg?dataurl"
|
||||||
|
|
@ -34,15 +33,9 @@
|
||||||
addRelay: (url: string) => $messagingRelayLists.addUrl(url).then(publish),
|
addRelay: (url: string) => $messagingRelayLists.addUrl(url).then(publish),
|
||||||
removeRelay: (url: string) => $messagingRelayLists.removeUrl(url).then(publish),
|
removeRelay: (url: string) => $messagingRelayLists.removeUrl(url).then(publish),
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={markAsRead}>
|
<Button onclick={markAsRead}>
|
||||||
<Icon size={4} icon={Check} />
|
<Icon size={4} icon={Check} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {Maybe, MaybeAsync} from "@welshman/lib"
|
import type {MaybeAsync} from "@welshman/lib"
|
||||||
import {MESSAGE} from "@welshman/util"
|
import {MESSAGE} from "@welshman/util"
|
||||||
import type {EventContent} from "@welshman/util"
|
import type {EventContent} from "@welshman/util"
|
||||||
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
|
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
import Plane from "@assets/icons/plane-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||||
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||||
|
|
@ -115,10 +114,6 @@
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showMenu = () => tippy?.show()
|
|
||||||
|
|
||||||
const hideMenu = () => tippy?.hide()
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if ($uploading || disabled || sending) {
|
if ($uploading || disabled || sending) {
|
||||||
return
|
return
|
||||||
|
|
@ -145,7 +140,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let sending = false
|
let sending = false
|
||||||
let tippy: Maybe<TippyController> = $state()
|
|
||||||
let content = $state(
|
let content = $state(
|
||||||
initialValues?.type === "text" ? initialValues.value : (draft?.get()?.content ?? ""),
|
initialValues?.type === "text" ? initialValues.value : (draft?.get()?.content ?? ""),
|
||||||
)
|
)
|
||||||
|
|
@ -212,15 +206,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{#if url}
|
{#if url}
|
||||||
<Button disabled={$uploading} onclick={showMenu} class={buttonClass}>
|
<MenuButton
|
||||||
<Tippy
|
|
||||||
bind:controller={tippy}
|
|
||||||
component={ComposeMenu}
|
component={ComposeMenu}
|
||||||
props={{url, h, onClick: hideMenu}}
|
componentProps={{url, h}}
|
||||||
params={{trigger: "manual", interactive: true}}>
|
placement="top"
|
||||||
|
disabled={$uploading}
|
||||||
|
class={buttonClass}>
|
||||||
<Icon icon={WidgetAdd} />
|
<Icon icon={WidgetAdd} />
|
||||||
</Tippy>
|
</MenuButton>
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
<EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
|
<EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
import NotesMinimalistic from "@assets/icons/notes-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 createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
|
||||||
|
|
||||||
const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true}))
|
const createArticle = () => navigate(makeArticleCreatePath(url, {h, shareToChat: true}))
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={createGoal}>
|
<Button onclick={createGoal}>
|
||||||
<Icon size={4} icon={StarFallMinimalistic} />
|
<Icon size={4} icon={StarFallMinimalistic} />
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {Maybe} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
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 EventMenu from "@app/components/EventMenu.svelte"
|
import EventMenu from "@app/components/EventMenu.svelte"
|
||||||
import EventReactButtons from "@app/components/EventReactButtons.svelte"
|
import EventReactButtons from "@app/components/EventReactButtons.svelte"
|
||||||
|
|
||||||
|
|
@ -19,25 +14,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, noun, event, hideZap, customActions}: Props = $props()
|
const {url, noun, event, hideZap, customActions}: Props = $props()
|
||||||
|
|
||||||
const showPopover = () => tippy?.show()
|
|
||||||
|
|
||||||
const hidePopover = () => tippy?.hide()
|
|
||||||
|
|
||||||
let tippy: Maybe<TippyController> = $state()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- The compact segmented control a card footer gets. EventActionBar is the full-width
|
<!-- The compact segmented control a card footer gets. EventActionBar is the full-width
|
||||||
version a detail page gets. -->
|
version a detail page gets. -->
|
||||||
<div class="items-center join">
|
<div class="items-center join">
|
||||||
<EventReactButtons {url} {event} {hideZap} class="button button-neutral button-xs join-item" />
|
<EventReactButtons {url} {event} {hideZap} class="button button-neutral button-xs join-item" />
|
||||||
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
|
<MenuButton
|
||||||
<Tippy
|
|
||||||
bind:controller={tippy}
|
|
||||||
component={EventMenu}
|
component={EventMenu}
|
||||||
props={{url, noun, event, customActions, onClick: hidePopover}}
|
componentProps={{url, noun, event, customActions}}
|
||||||
params={{trigger: "manual", interactive: true}}>
|
placement="top"
|
||||||
<Icon icon={MenuDots} size={4} />
|
iconSize={4}
|
||||||
</Tippy>
|
class="flex join-item button button-neutral button-xs" />
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {COMMENT} from "@welshman/util"
|
import {COMMENT} from "@welshman/util"
|
||||||
|
|
@ -44,15 +43,9 @@
|
||||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||||
|
|
||||||
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event})
|
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={showInfo}>
|
<Button onclick={showInfo}>
|
||||||
<Icon size={4} icon={Code2} />
|
<Icon size={4} icon={Code2} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {PinReader} from "@welshman/domain"
|
import type {PinReader} from "@welshman/domain"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
|
|
@ -53,15 +52,9 @@
|
||||||
message: "Delete this link?",
|
message: "Delete this link?",
|
||||||
confirm: deletePin,
|
confirm: deletePin,
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={showInfo}>
|
<Button onclick={showInfo}>
|
||||||
<Icon icon={Code2} />
|
<Icon icon={Code2} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -10,15 +9,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onDelete, onClick}: Props = $props()
|
const {onDelete, onClick}: Props = $props()
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={onDelete}>
|
<Button onclick={onDelete}>
|
||||||
<Icon icon={TrashBin2} />
|
<Icon icon={TrashBin2} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import {removeUndefined} from "@welshman/lib"
|
import {removeUndefined} from "@welshman/lib"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
|
@ -25,15 +24,9 @@
|
||||||
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
|
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
|
||||||
|
|
||||||
const showShare = () => pushModal(ProfileQrCode, {pubkey})
|
const showShare = () => pushModal(ProfileQrCode, {pubkey})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button onclick={showShare}>
|
<Button onclick={showShare}>
|
||||||
<Icon size={4} icon={ShareCircle} />
|
<Icon size={4} icon={ShareCircle} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {Report} from "@welshman/domain"
|
import {Report} from "@welshman/domain"
|
||||||
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
|
||||||
|
|
@ -87,15 +86,9 @@
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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}
|
{#if event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={deleteReport}>
|
<Button onclick={deleteReport}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {MembershipStatus, publish} from "@welshman/app"
|
import {MembershipStatus, publish} from "@welshman/app"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
|
@ -70,14 +69,9 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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}
|
{#if $userIsAdmin}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={startEdit}>
|
<Button onclick={startEdit}>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import MenuButton from "@lib/components/MenuButton.svelte"
|
||||||
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
import ThunkFailure from "@app/components/ThunkFailure.svelte"
|
||||||
import {publishReaction, retractReaction} from "@app/reactions"
|
import {publishReaction, retractReaction} from "@app/reactions"
|
||||||
import {deriveDisplaysByPubkey} from "@app/social"
|
import {deriveDisplaysByPubkey} from "@app/social"
|
||||||
|
|
@ -22,7 +23,7 @@
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
import RoomItemZapButton from "@app/components/RoomItemZapButton.svelte"
|
import RoomItemZapButton from "@app/components/RoomItemZapButton.svelte"
|
||||||
import RoomItemEmojiButton from "@app/components/RoomItemEmojiButton.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 RoomItemMenuMobile from "@app/components/RoomItemMenuMobile.svelte"
|
||||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||||
import {events, profiles, reader, thunks, user} from "@app/core"
|
import {events, profiles, reader, thunks, user} from "@app/core"
|
||||||
|
|
@ -189,7 +190,14 @@
|
||||||
<Icon icon={Pen} size={4} />
|
<Icon icon={Pen} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</TapTarget>
|
</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">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -33,15 +32,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
<li>
|
||||||
<Button class="text-error" onclick={removeMember}>
|
<Button class="text-error" onclick={removeMember}>
|
||||||
<Icon icon={MinusCircle} />
|
<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">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import Restart from "@assets/icons/restart.svg?dataurl"
|
import Restart from "@assets/icons/restart.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -39,15 +38,9 @@
|
||||||
$spaceManagement.loadBannedPubkeys(url)
|
$spaceManagement.loadBannedPubkeys(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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}
|
{#if $permissions.unban}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={unbanMember}>
|
<Button onclick={unbanMember}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
|
||||||
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
import UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
||||||
|
|
@ -61,15 +60,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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}
|
{#if $permissions.assignRoles || $permissions.unassignRoles}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={editRoles}>
|
<Button onclick={editRoles}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
|
||||||
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
|
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
|
||||||
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
|
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
|
@ -32,13 +31,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMenu = () => {
|
const closeMenu = () => {
|
||||||
showMenu = !showMenu
|
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)
|
let showMenu = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onmouseup={showMenu ? onMouseUp : undefined}
|
||||||
|
onkeydown={showMenu ? onKeyDown : undefined} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
class={cx(
|
class={cx(
|
||||||
mobile
|
mobile
|
||||||
|
|
@ -64,9 +76,9 @@
|
||||||
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{#if showMenu && !mobile}
|
{#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">
|
<ul transition:fly class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
|
||||||
<SpaceMenuActions {url} />
|
<SpaceMenuActions {url} />
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
|
||||||
import type {RelayRoleReader} from "@welshman/domain"
|
import type {RelayRoleReader} from "@welshman/domain"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Pen from "@assets/icons/pen.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.`,
|
message: `Delete the "${role.label()}" role? Members will keep their space membership.`,
|
||||||
confirm: deleteRole,
|
confirm: deleteRole,
|
||||||
})
|
})
|
||||||
|
|
||||||
let ul: Element
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
ul.addEventListener("click", onClick)
|
|
||||||
})
|
|
||||||
</script>
|
</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}
|
{#if $permissions.assignRoles}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={addMembers}>
|
<Button onclick={addMembers}>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,25 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {spec} from "@welshman/lib"
|
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 Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
|
import VerifiedCheck from "@assets/icons/verified-check.svg?dataurl"
|
||||||
import ShieldWarning from "@assets/icons/shield-warning.svg?dataurl"
|
import ShieldWarning from "@assets/icons/shield-warning.svg?dataurl"
|
||||||
import Copy from "@assets/icons/copy.svg?dataurl"
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
import Refresh from "@assets/icons/refresh.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 {ucFirst} from "@lib/util"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Link from "@lib/components/Link.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 Divider from "@lib/components/Divider.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
|
||||||
import RelayIcon from "@app/components/RelayIcon.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 CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
|
||||||
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
|
import {pushModal} from "@app/modal"
|
||||||
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 {clip, pushToast} from "@app/toast"
|
import {clip, pushToast} from "@app/toast"
|
||||||
import {
|
import {
|
||||||
RELAY_FLAG_DEFAULTS,
|
RELAY_FLAG_DEFAULTS,
|
||||||
|
|
@ -45,8 +34,6 @@
|
||||||
isPaidPlan,
|
isPaidPlan,
|
||||||
updateRelay,
|
updateRelay,
|
||||||
getRelay,
|
getRelay,
|
||||||
deactivateRelay,
|
|
||||||
reactivateRelay,
|
|
||||||
deriveRelayMembers,
|
deriveRelayMembers,
|
||||||
type HostedRelay,
|
type HostedRelay,
|
||||||
type RelayFlag,
|
type RelayFlag,
|
||||||
|
|
@ -63,7 +50,6 @@
|
||||||
const members = deriveRelayMembers(relay.id)
|
const members = deriveRelayMembers(relay.id)
|
||||||
|
|
||||||
const host = $derived(relayHost(relay))
|
const host = $derived(relayHost(relay))
|
||||||
const relayLabel = $derived(relay.info_name || relay.subdomain)
|
|
||||||
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
|
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
|
||||||
const recordTarget = $derived(canonicalRelayHost(relay))
|
const recordTarget = $derived(canonicalRelayHost(relay))
|
||||||
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record.
|
// 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 = () => {
|
const openCustomDomain = () => {
|
||||||
pushModal(CustomDomainModal, {relay})
|
pushModal(CustomDomainModal, {relay})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMenu = () => {
|
|
||||||
showMenu = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMenu = () => {
|
|
||||||
showMenu = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyTarget = () => clip(recordTarget)
|
const copyTarget = () => clip(recordTarget)
|
||||||
|
|
||||||
// Verification runs in a backend poller; reload to pick up the result.
|
// 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)
|
let loading = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -216,7 +122,7 @@
|
||||||
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
|
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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"}>
|
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
|
||||||
{ucFirst(relay.status.replace(/_/g, " "))}
|
{ucFirst(relay.status.replace(/_/g, " "))}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -233,53 +139,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative shrink-0">
|
<MenuButton
|
||||||
<Button
|
|
||||||
class="button button-neutral button-circle"
|
class="button button-neutral button-circle"
|
||||||
aria-label="Relay actions"
|
aria-label="Relay actions"
|
||||||
onclick={openMenu}>
|
component={RelayDetailMenu}
|
||||||
<Icon icon={MenuDots} />
|
componentProps={{relay}} />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if relay.sync_error}
|
{#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">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
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 type {Maybe} from "@welshman/lib"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tippyParams?: Record<string, unknown>
|
tippyParams?: Partial<TippyProps>
|
||||||
onEmoji: (emoji: string) => void
|
onEmoji: (emoji: string) => void
|
||||||
allowKaomoji?: boolean
|
allowKaomoji?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
|
|
@ -32,28 +32,15 @@
|
||||||
tippy?.hide()
|
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()
|
let tippy: Maybe<TippyController> = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
|
|
||||||
|
|
||||||
<Button onclick={open} class={className} {...restProps}>
|
<Button onclick={open} class={className} {...restProps}>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:controller={tippy}
|
bind:controller={tippy}
|
||||||
class="flex"
|
class="flex"
|
||||||
component={EmojiPicker}
|
component={EmojiPicker}
|
||||||
|
strayMargin={allowKaomoji ? undefined : 100}
|
||||||
props={{onClick, allowKaomoji}}
|
props={{onClick, allowKaomoji}}
|
||||||
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {between, throttle} from "@welshman/lib"
|
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -32,22 +31,13 @@
|
||||||
close()
|
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()
|
let tippy: Maybe<TippyController> = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onmousemove={onMouseMove} />
|
|
||||||
|
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:controller={tippy}
|
bind:controller={tippy}
|
||||||
component={IconPickerPopover}
|
component={IconPickerPopover}
|
||||||
|
strayMargin={50}
|
||||||
props={{onSelect: onClick}}
|
props={{onSelect: onClick}}
|
||||||
params={{trigger: "manual", interactive: true, placement: "top-end"}}>
|
params={{trigger: "manual", interactive: true, placement: "top-end"}}>
|
||||||
<Button onclick={open} class={props.class}>
|
<Button onclick={open} class={props.class}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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 type {Maybe} from "@welshman/lib"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -10,16 +11,24 @@
|
||||||
type Props = {
|
type Props = {
|
||||||
component: Component<any>
|
component: Component<any>
|
||||||
componentProps?: Record<string, any>
|
componentProps?: Record<string, any>
|
||||||
|
placement?: Placement
|
||||||
|
strayMargin?: number
|
||||||
class?: string
|
class?: string
|
||||||
iconSize?: number
|
iconSize?: number
|
||||||
|
disabled?: boolean
|
||||||
|
children?: Snippet
|
||||||
"aria-label"?: string
|
"aria-label"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
component,
|
component,
|
||||||
componentProps = {},
|
componentProps = {},
|
||||||
|
placement = "bottom-end",
|
||||||
|
strayMargin,
|
||||||
class: className = "button button-ghost button-sm button-circle",
|
class: className = "button button-ghost button-sm button-circle",
|
||||||
iconSize,
|
iconSize,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
"aria-label": ariaLabel,
|
"aria-label": ariaLabel,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
|
|
@ -30,12 +39,17 @@
|
||||||
let tippy: Maybe<TippyController> = $state()
|
let tippy: Maybe<TippyController> = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button class={className} aria-label={ariaLabel} onclick={showPopover}>
|
<Button class={className} aria-label={ariaLabel} {disabled} onclick={showPopover}>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:controller={tippy}
|
bind:controller={tippy}
|
||||||
{component}
|
{component}
|
||||||
|
{strayMargin}
|
||||||
props={{...componentProps, onClick: hidePopover}}
|
props={{...componentProps, onClick: hidePopover}}
|
||||||
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>
|
params={{trigger: "manual", interactive: true, placement}}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
<Icon icon={MenuDots} size={iconSize} />
|
<Icon icon={MenuDots} size={iconSize} />
|
||||||
|
{/if}
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</Button>
|
</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
|
hide: () => void
|
||||||
toggle: () => void
|
toggle: () => void
|
||||||
visible: boolean
|
visible: boolean
|
||||||
rect: () => Maybe<DOMRect>
|
|
||||||
content: Maybe<Content>
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "tippy.js/animations/shift-away.css"
|
import "tippy.js/animations/shift-away.css"
|
||||||
|
|
||||||
import tippy from "tippy.js"
|
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 {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 {
|
let {
|
||||||
component,
|
component,
|
||||||
children = undefined,
|
children,
|
||||||
props = {},
|
props = {},
|
||||||
params = {},
|
params = {},
|
||||||
|
strayMargin,
|
||||||
controller = $bindable(),
|
controller = $bindable(),
|
||||||
...restProps
|
class: className,
|
||||||
} = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
const target = document.createElement("div")
|
const target = document.createElement("div")
|
||||||
|
|
||||||
|
|
@ -56,15 +85,24 @@
|
||||||
: element.closest<HTMLElement>("button, [href], input, [tabindex]") ||
|
: element.closest<HTMLElement>("button, [href], input, [tabindex]") ||
|
||||||
element.querySelector<HTMLElement>("button, [href], input, [tabindex]") ||
|
element.querySelector<HTMLElement>("button, [href], input, [tabindex]") ||
|
||||||
undefined
|
undefined
|
||||||
|
|
||||||
|
if (component) {
|
||||||
content ??= mount(component, {target, props: mountedProps})
|
content ??= mount(component, {target, props: mountedProps})
|
||||||
|
}
|
||||||
|
|
||||||
visible = true
|
visible = true
|
||||||
document.addEventListener("keydown", onKeyDown)
|
document.addEventListener("keydown", onKeyDown)
|
||||||
|
|
||||||
|
if (strayMargin) {
|
||||||
|
document.addEventListener("mousemove", onMouseMove)
|
||||||
|
}
|
||||||
|
|
||||||
return params.onShow?.(instance)
|
return params.onShow?.(instance)
|
||||||
},
|
},
|
||||||
onHidden: (instance: Instance) => {
|
onHidden: (instance: Instance) => {
|
||||||
visible = false
|
visible = false
|
||||||
document.removeEventListener("keydown", onKeyDown)
|
document.removeEventListener("keydown", onKeyDown)
|
||||||
|
document.removeEventListener("mousemove", onMouseMove)
|
||||||
|
|
||||||
return params.onHidden?.(instance)
|
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 = {
|
controller = {
|
||||||
show: () => create().show(),
|
show: () => create().show(),
|
||||||
hide: () => popover?.hide(),
|
hide: () => popover?.hide(),
|
||||||
|
|
@ -95,7 +147,6 @@
|
||||||
create().show()
|
create().show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rect: () => popover?.popper.getBoundingClientRect(),
|
|
||||||
get visible() {
|
get visible() {
|
||||||
return visible
|
return visible
|
||||||
},
|
},
|
||||||
|
|
@ -115,6 +166,7 @@
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", onKeyDown)
|
document.removeEventListener("keydown", onKeyDown)
|
||||||
|
document.removeEventListener("mousemove", onMouseMove)
|
||||||
popover?.destroy()
|
popover?.destroy()
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
|
|
@ -124,6 +176,6 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={element} class={restProps.class}>
|
<div bind:this={element} class={className}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</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">
|
<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"
|
type Props = {
|
||||||
import {onMount} from "svelte"
|
content: string
|
||||||
import {getTippyTarget, isMobile} from "@lib/html"
|
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>
|
</script>
|
||||||
|
|
||||||
<div bind:this={element} class={restProps.class}>
|
<Tippy class={className} params={{content, theme: "tooltip"}}>
|
||||||
{@render children?.()}
|
{@render children()}
|
||||||
</div>
|
</Tippy>
|
||||||
|
|
|
||||||
|
|
@ -419,12 +419,6 @@ body.keyboard-open .room {
|
||||||
padding: 0.7rem 1rem !important;
|
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 — the .emoji-picker frame around it supplies the border */
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--surface);
|
--background: var(--surface);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
display: none !important;
|
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 {
|
.tippy-target {
|
||||||
@apply z-tooltip pointer-events-none fixed inset-0;
|
@apply z-tooltip pointer-events-none fixed inset-0;
|
||||||
}
|
}
|
||||||
|
|
@ -50,4 +50,34 @@
|
||||||
.tippy-target > * {
|
.tippy-target > * {
|
||||||
@apply pointer-events-auto;
|
@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 {readable} from "svelte/store"
|
||||||
import {sleep, randomId} from "@welshman/lib"
|
import {sleep, randomId} from "@welshman/lib"
|
||||||
import type {Maybe} from "@welshman/lib"
|
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
export {preventDefault, stopPropagation} from "svelte/legacy"
|
export {preventDefault, stopPropagation} from "svelte/legacy"
|
||||||
|
|
||||||
|
|
@ -121,23 +120,6 @@ export const createScroller = ({
|
||||||
|
|
||||||
export const isMobile = "ontouchstart" in document.documentElement
|
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) => {
|
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.
|
// 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())) {
|
if (["android", "ios"].includes(Capacitor.getPlatform())) {
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,15 @@
|
||||||
import {removeUndefined, sortBy} from "@welshman/lib"
|
import {removeUndefined, sortBy} from "@welshman/lib"
|
||||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.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 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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.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 PageContent from "@lib/components/PageContent.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import SpaceMember from "@app/components/SpaceMember.svelte"
|
import SpaceMember from "@app/components/SpaceMember.svelte"
|
||||||
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
import SpaceInvite from "@app/components/SpaceInvite.svelte"
|
||||||
import SpaceAdmins from "@app/components/SpaceAdmins.svelte"
|
import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte"
|
||||||
import SpaceRoles from "@app/components/SpaceRoles.svelte"
|
|
||||||
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
|
|
||||||
import {deriveSpacePermissions} from "@app/management"
|
import {deriveSpacePermissions} from "@app/management"
|
||||||
import {deriveSpaceMemberRoles} from "@app/roles"
|
import {deriveSpaceMemberRoles} from "@app/roles"
|
||||||
import {relayMemberLists, relayRoles} from "@app/core"
|
import {relayMemberLists, relayRoles} from "@app/core"
|
||||||
|
|
@ -47,27 +41,7 @@
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
let menuOpen = $state(false)
|
const inviteMembers = () => pushModal(SpaceInvite, {url})
|
||||||
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A role section matches on its own name, so it survives a term its members don't match.
|
// A role section matches on its own name, so it survives a term its members don't match.
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
|
|
@ -112,46 +86,12 @@
|
||||||
Invite people
|
Invite people
|
||||||
</Button>
|
</Button>
|
||||||
{#if $permissions.directoryMenu}
|
{#if $permissions.directoryMenu}
|
||||||
<div class="relative">
|
<MenuButton
|
||||||
<Button
|
|
||||||
class="button button-neutral button-sm button-square"
|
class="button button-neutral button-sm button-square"
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
onclick={() => (menuOpen = !menuOpen)}>
|
iconSize={4}
|
||||||
<Icon size={4} icon={MenuDots} />
|
component={SpaceDirectoryMenu}
|
||||||
</Button>
|
componentProps={{url}} />
|
||||||
{#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>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue