Clean up convention violations, dead code, and daisyUI leftovers

This commit is contained in:
Jon Staab 2026-07-27 15:38:23 -07:00
parent c0c8354a76
commit a2c5a8e2ff
38 changed files with 120 additions and 224 deletions

View file

@ -6,7 +6,7 @@ Flotilla is a Nostr "relays as groups" community chat client. It implements NIP-
- SvelteKit 5.48+ with TypeScript 5.9+
- Capacitor for cross-platform (Web/PWA, Android, iOS)
- TailwindCSS + DaisyUI for styling
- TailwindCSS for styling
- Welshman library suite for Nostr protocol
- IndexedDB for local storage
- Vite for building

View file

@ -26,6 +26,10 @@ export default [
parser: ts.parser,
},
},
rules: {
// eslint doesn't see type parameters declared by <script generics="...">
"no-undef": "off",
},
},
{
ignores: ["build/", ".svelte-kit/", "dist/"],

View file

@ -35,7 +35,7 @@
"postcss": "^8.5.15",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"sharp": "0.35.0-rc.0",
"sharp": "^0.35.3",
"svelte": "^5.55.9",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.2.2",

Binary file not shown.

View file

@ -16,4 +16,4 @@ minimumReleaseAgeExclude:
- '@welshman/store'
- '@welshman/util'
overrides:
sharp: 0.35.0-rc.0
sharp: ^0.35.3

View file

@ -1,16 +1,6 @@
import {derived, get, writable, type Readable} from "svelte/store"
import {
dissoc,
filterVals,
fromPairs,
isDefined,
last,
poll,
randomId,
sleep,
tryCatch,
} from "@welshman/lib"
import {AuthStatus, load, Pool, request, SocketStatus} from "@welshman/net"
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
import {AuthStatus, Pool, request, SocketStatus} from "@welshman/net"
import {
displayRelayUrl,
getTagValue,
@ -42,8 +32,6 @@ export type InviteData = {
code?: string
}
export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "noclaim" | "ready"
export type JoinRequestParams = {
url: string
claim: string
@ -116,21 +104,6 @@ export const deriveRelayAuthError = (url: string) =>
}
})
export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [RELAY_INVITE], limit: 1}]
const events = await load({filters, relays: [url]})
if (events.length > 0) {
return getTagValue("claim", events[0].tags)
}
}
export const requestRelayClaims = async (urls: string[]) =>
filterVals(
isDefined,
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
)
export const makeJoinRequest = (params: JoinRequestParams) =>
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})

View file

@ -29,8 +29,6 @@ import {pushToast} from "@app/toast"
export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit"
export {supportsAudioOutputSelection}
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
@ -105,15 +103,6 @@ export const isParticipantSpeaking = derived(
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
export const isLocalSpeaking = derived(
[currentCallSession, speakingParticipants],
([$session, $speaking]) => {
if (!$session?.livekit) return false
const local = participantFromLiveKitIdentity($session.livekit.localParticipant.identity)
return $speaking.some(sp => participantKey(sp) === participantKey(local))
},
)
export const videoTrackRevision = writable(0)
export const triggerVideoTrackRevision = () => {

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import cx from "classnames"
import {page} from "$app/stores"
import {remove, uniq, formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -35,8 +36,11 @@
<Button class="flex flex-col justify-start gap-1 w-full" onclick={openChat}>
<div
class="cursor-pointer border-t border-solid px-3 py-2 transition-colors hover:bg-surface {props.class}"
class:bg-surface={active}
class={cx(
"cursor-pointer border-t border-solid px-3 py-2 transition-colors hover:bg-surface",
props.class,
{"bg-surface": active},
)}
style="border-color: var(--line)">
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">

View file

@ -12,6 +12,8 @@
}
const {pubkeys}: Props = $props()
const back = () => history.back()
</script>
<Modal>
@ -28,6 +30,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -72,8 +72,7 @@
{/if}
<div
data-event={event.id}
class="group flex items-center justify-end gap-1 px-2"
class:flex-row-reverse={!isOwn}>
class={cx("group flex items-center justify-end gap-1 px-2", {"flex-row-reverse": !isOwn})}>
{#if !isMobile}
<Tippy
bind:popover

View file

@ -35,6 +35,7 @@
const copyLink = () => clip(nevent1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)
const back = () => history.back()
const formatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "long",
@ -110,6 +111,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-primary grow" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary grow" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -6,6 +6,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script>
<Modal
@ -37,5 +39,5 @@
href="https://nostrapps.com#signers">nostrapps.com</Link
>.
</p>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal>

View file

@ -6,6 +6,8 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script>
<Modal
@ -27,5 +29,5 @@
class="underline">nostr.how</Link
>.
</p>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal>

View file

@ -5,6 +5,8 @@
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
const back = () => history.back()
</script>
<Modal
@ -28,5 +30,5 @@
To learn more about how to manage your keys, or to set up an account, try
<Link external class="link" href="https://nosta.me/">nosta.me</Link>.
</p>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalBody></Modal>

View file

@ -7,6 +7,8 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {PLATFORM_NAME} from "@app/env"
const back = () => history.back()
</script>
<Modal>
@ -27,6 +29,6 @@
</p>
</ModalBody>
<ModalFooter>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -88,10 +88,7 @@
Title
{/snippet}
{#snippet input()}
<input
bind:value={title}
class="input input-bordered w-full"
placeholder="Optional title" />
<input bind:value={title} class="input w-full" placeholder="Optional title" />
{/snippet}
</Field>
<Field>
@ -133,7 +130,7 @@
<p>A URL or nostr link (note, nevent, naddr, npub, or nprofile).</p>
{/snippet}
{#snippet input()}
<input bind:value class="input input-bordered w-full" placeholder="URL or nevent..." />
<input bind:value class="input w-full" placeholder="URL or nevent..." />
{/snippet}
</Field>
</ModalBody>

View file

@ -65,7 +65,7 @@
Title
{/snippet}
{#snippet input()}
<input bind:value={title} class="input input-bordered w-full" placeholder="Shelf title" />
<input bind:value={title} class="input w-full" placeholder="Shelf title" />
{/snippet}
</Field>
<Field>
@ -75,7 +75,7 @@
{#snippet input()}
<input
bind:value={description}
class="input input-bordered w-full"
class="input w-full"
placeholder="What's this shelf about?" />
{/snippet}
</Field>

View file

@ -12,7 +12,7 @@
import {canEnforceNip70} from "@app/relays"
import {makePollPath, makeSpacePath} from "@app/routes"
interface Props {
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type {Snippet} from "svelte"
import cx from "classnames"
import {userProfile} from "@welshman/app"
import Letter from "@assets/icons/letter.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@ -33,7 +34,7 @@
)
</script>
<div class="primary-nav" class:justify-between={PLATFORM_RELAYS.length === 0}>
<div class={cx("primary-nav", {"justify-between": PLATFORM_RELAYS.length === 0})}>
<PrimaryNavSpaces />
{#if PLATFORM_RELAYS.length > 0}
<Divider />

View file

@ -41,6 +41,7 @@
const copyLink = () => clip(nprofile1)
const copyPubkey = () => clip(npub1)
const copyJson = () => clip(json)
const back = () => history.back()
const formatter = new Intl.DateTimeFormat(LOCALE, {
dateStyle: "long",
@ -116,6 +117,6 @@
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-primary grow" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary grow" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -16,6 +16,8 @@
}
const {subtitle = "", pubkeys, url, ...restProps}: Props = $props()
const back = () => history.back()
</script>
<Modal>
@ -31,6 +33,6 @@
{/each}
</ModalBody>
<ModalFooter>
<Button class="button button-primary" onclick={() => history.back()}>Got it</Button>
<Button class="button button-primary" onclick={back}>Got it</Button>
</ModalFooter>
</Modal>

View file

@ -515,8 +515,9 @@
{#if type === "new-messages"}
<div
{id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"

View file

@ -131,7 +131,7 @@
</Tippy>
</Button>
</div>
<div class="chat-editor grow overflow-hidden rounded-box">
<div class="chat-editor grow overflow-hidden">
<EditorContent {autofocus} {editor} />
</div>
<Button

View file

@ -161,8 +161,10 @@
<strong class="flex items-center gap-1 relative">
<RelayName {url} class="truncate min-w-0" />
<div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all opacity-0"
class:opacity-100={$userIsAdmin && $actionItems.length > 0}>
class={cx(
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
)}>
</div>
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />

View file

@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {parse, renderAsHtml} from "@welshman/content"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
@ -84,16 +85,16 @@
{#key $toast.id}
<div
role="alert"
class="card relative flex justify-center whitespace-normal text-left"
class:text-content={theme === "info"}
class:text-error={theme === "error"}
style={theme === "error" ? "border-color: var(--error)" : ""}>
class={cx("card relative flex justify-center whitespace-normal text-left", {
"text-content": theme === "info",
"border-error text-error": theme === "error",
})}>
<Button
class="button button-neutral button-xs button-circle absolute -top-2 -right-2 hidden md:inline-flex flex justify-center items-center"
onclick={onClose}>
<Icon icon={Close} size={4} />
</Button>
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
<p class={cx("md:pr-6", {"welshman-content-error": theme === "error"})}>
{#if $toast.message}
{@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action}

View file

@ -247,7 +247,7 @@
</div>
{/if}
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-surface/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}

View file

@ -1,18 +1,9 @@
import {deriveItems, deriveItemsByKey, getter, makeDeriveItem, makeLoadItem} from "@welshman/store"
import {
ensurePlaintext,
makeOutboxLoader,
makeUserData,
makeUserLoader,
repository,
tracker,
} from "@welshman/app"
import {derived, writable} from "svelte/store"
import {repository, tracker} from "@welshman/app"
import {writable} from "svelte/store"
import {
batch,
between,
call,
groupBy,
insertAt,
int,
now,
@ -22,87 +13,13 @@ import {
MONTH,
YEAR,
} from "@welshman/lib"
import {
Address,
EVENT_TIME,
FEED,
FEEDS,
asDecryptedEvent,
getAddress,
getIdFilters,
getTagValue,
matchFilters,
readList,
} from "@welshman/util"
import type {Filter, PublishedList, TrustedEvent} from "@welshman/util"
import {load, mergeRepositoryUpdates, request} from "@welshman/net"
import {EVENT_TIME, getAddress, getTagValue, matchFilters} from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util"
import {mergeRepositoryUpdates, request} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net"
import {Router} from "@welshman/router"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {readFeed} from "@lib/feeds"
import {getEventsForUrl} from "@app/repository"
export const feedsByAddress = deriveItemsByKey({
repository,
getKey: feed => getAddress(feed.event),
filters: [{kinds: [FEED]}],
eventToItem: readFeed,
})
export const getFeedsByAddress = getter(feedsByAddress)
export const feeds = deriveItems(feedsByAddress)
export const getFeeds = getter(feeds)
export const getFeed = (address: string) => getFeedsByAddress().get(address)
export const fetchFeed = (address: string) => {
const {pubkey} = Address.from(address)
return load({
relays: Router.get().FromPubkey(pubkey).getUrls(),
filters: getIdFilters([address]),
})
}
export const loadFeed = makeLoadItem(fetchFeed, getFeed)
export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed)
export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds))
export const getFeedsByPubkey = getter(feedsByPubkey)
export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey)
export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey)
export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey)
export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey)
export const feedFavoritesByPubkey = deriveItemsByKey<PublishedList>({
repository,
getKey: list => list.event.pubkey,
filters: [{kinds: [FEEDS]}],
eventToItem: async event =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey)
export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey)
export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites)
export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites)
export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites)
export const makeFeed = ({
relays,

View file

@ -19,7 +19,7 @@ import {
getTagValues,
sortEventsAsc,
} from "@welshman/util"
import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util"
import type {Filter, ManagementRequest, PublishedRoomMeta, TrustedEvent} from "@welshman/util"
import {first, simpleCache, sortBy, spec, uniq} from "@welshman/lib"
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
import {load} from "@welshman/net"
@ -47,7 +47,7 @@ const DEFAULT_LIGHTNESS = 0.5
// Parse the hue from a ["color", hue] tag, falling back to 0.
export const parseRoleColor = (tags: string[][]): number => {
const hue = parseFloat(first(getTags("color", tags))?.[1] ?? "")
const hue = parseFloat(getTagValue("color", tags) ?? "")
return isNaN(hue) ? 0 : hue
}
@ -105,6 +105,11 @@ export const deriveSpaceMemberRoles = (url: string) =>
const reloadRole = (url: string, id: string) =>
load({relays: [url], filters: [{kinds: [RELAY_ROLE], "#d": [id]}]})
// Roles extend NIP-86: these methods aren't in welshman's ManagementMethod, and hue and
// order go over the wire as JSON numbers, not strings, per the relay's implementation.
const manageRole = (url: string, method: string, params: (string | number)[]) =>
manageRelay(url, {method, params} as ManagementRequest)
export const createRole = async (
url: string,
id: string,
@ -113,11 +118,7 @@ export const createRole = async (
color: number,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "createrole" as ManagementMethod,
// hue and order go over the wire as JSON numbers, not strings, per the relay's NIP-86 impl.
params: [id, label, description, color, order] as unknown as string[],
})
const {error} = await manageRole(url, "createrole", [id, label, description, color, order])
if (!error) {
await reloadRole(url, id)
@ -134,11 +135,7 @@ export const editRole = async (
color: number,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "editrole" as ManagementMethod,
// hue and order go over the wire as JSON numbers, not strings, per the relay's NIP-86 impl.
params: [id, label, description, color, order] as unknown as string[],
})
const {error} = await manageRole(url, "editrole", [id, label, description, color, order])
if (!error) {
await reloadRole(url, id)
@ -148,10 +145,7 @@ export const editRole = async (
}
export const deleteRole = async (url: string, id: string): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "deleterole" as ManagementMethod,
params: [id],
})
const {error} = await manageRole(url, "deleterole", [id])
return error
}
@ -161,10 +155,7 @@ export const assignRole = async (
pubkey: string,
roleId: string,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "assignrole" as ManagementMethod,
params: [pubkey, roleId],
})
const {error} = await manageRole(url, "assignrole", [pubkey, roleId])
return error
}
@ -174,10 +165,7 @@ export const unassignRole = async (
pubkey: string,
roleId: string,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "unassignrole" as ManagementMethod,
params: [pubkey, roleId],
})
const {error} = await manageRole(url, "unassignrole", [pubkey, roleId])
return error
}

View file

@ -1,17 +1,14 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {HTMLAttributes} from "svelte/elements"
import cx from "classnames"
const {
children,
variant = "neutral",
...restProps
}: {
type Props = HTMLAttributes<HTMLSpanElement> & {
children?: Snippet
variant?: "neutral" | "primary" | "warning"
class?: string
[key: string]: any
} = $props()
}
const {children, variant = "neutral", ...restProps}: Props = $props()
const className = $derived(cx("badge", `badge-${variant}`, restProps.class))
</script>

View file

@ -1,7 +1,16 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {HTMLAttributes} from "svelte/elements"
import cx from "classnames"
type Props = HTMLAttributes<HTMLElement> & {
children?: Snippet
tag?: string
sm?: boolean
flat?: boolean
interactive?: boolean
}
const {
children,
tag = "div",
@ -9,15 +18,7 @@
flat = false,
interactive = false,
...restProps
}: {
children?: Snippet
tag?: string
sm?: boolean
flat?: boolean
interactive?: boolean
class?: string
[key: string]: any
} = $props()
}: Props = $props()
const className = $derived(
cx(

View file

@ -8,10 +8,9 @@
title?: Snippet
info?: Snippet
primary?: boolean
[key: string]: any
}
const {icon, title, info, primary, ...props}: Props = $props()
const {icon, title, info, primary}: Props = $props()
</script>
<div class="card-button {primary ? 'card-button-primary' : 'card-button-neutral'}">

View file

@ -1,5 +1,5 @@
<script lang="ts">
interface Props {
type Props = {
input?: import("svelte").Snippet
content?: import("svelte").Snippet
[key: string]: any

View file

@ -7,7 +7,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
interface Props {
type Props = {
value?: number | undefined
}

View file

@ -5,7 +5,7 @@
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
interface Props {
type Props = {
start?: number | undefined
end?: number | undefined
}

View file

@ -1,7 +1,15 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {HTMLInputAttributes} from "svelte/elements"
import cx from "classnames"
type Props = Omit<HTMLInputAttributes, "value"> & {
value?: string
before?: Snippet
after?: Snippet
sm?: boolean
}
let {
value = $bindable(),
before,
@ -9,14 +17,7 @@
sm = false,
class: className = "",
...restProps
}: {
value?: any
before?: Snippet
after?: Snippet
sm?: boolean
class?: string
[key: string]: any
} = $props()
}: Props = $props()
const wrapperClass = $derived(cx("input", "input-group", {"input-sm": sm}, className))
</script>

View file

@ -1,10 +1,10 @@
<script lang="ts">
<script lang="ts" generics="T">
import type {Snippet} from "svelte"
type Props = {
items: any[]
child: Snippet<[any]>
getKey: (item: any) => PropertyKey
items: T[]
child: Snippet<[T]>
getKey: (item: T) => PropertyKey
// Minimum column width, in tailwind spacing units (1 = 0.25rem). The browser
// fits as many columns as the container allows, so the layout stays
// responsive without media queries.

View file

@ -2,7 +2,7 @@
import cx from "classnames"
import type {Snippet} from "svelte"
interface Props {
type Props = {
element?: Element
children?: Snippet
noPad?: boolean

View file

@ -56,6 +56,14 @@
const back = () => history.back()
const toggleMenu = () => {
menuOpen = !menuOpen
}
const closeMenu = () => {
menuOpen = false
}
const showInfo = () => {
menuOpen = false
@ -122,11 +130,11 @@
<Button
class="button button-neutral button-sm button-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
onclick={toggleMenu}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">