Clean up search
This commit is contained in:
parent
74d3a25461
commit
9955a50add
21 changed files with 482 additions and 457 deletions
|
|
@ -88,6 +88,10 @@
|
||||||
@apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
|
@apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility card2-interactive {
|
||||||
|
@apply cursor-pointer hover:scale-101 transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
@utility column {
|
@utility column {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if warning}
|
{#if warning}
|
||||||
<div class="card2 card2-sm bg-alt row-2">
|
<div class="card2 card2-sm shadow-none row-2">
|
||||||
<Icon icon={Danger} />
|
<Icon icon={Danger} />
|
||||||
<p>
|
<p>
|
||||||
This note has been flagged by the author as "{warning}".<br />
|
This note has been flagged by the author as "{warning}".<br />
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if warning}
|
{#if warning}
|
||||||
<div class="card2 card2-sm bg-alt row-2">
|
<div class="card2 card2-sm shadow-none row-2">
|
||||||
<Icon icon={Danger} />
|
<Icon icon={Danger} />
|
||||||
<p>
|
<p>
|
||||||
This note has been flagged by the author as "{warning}".<br />
|
This note has been flagged by the author as "{warning}".<br />
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<NoteCard
|
<NoteCard
|
||||||
noShadow
|
|
||||||
event={$quote}
|
event={$quote}
|
||||||
{url}
|
{url}
|
||||||
class="border border-solid border-base-content/20 rounded-box p-4">
|
class="border border-solid border-base-content/20 rounded-box p-4">
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,13 @@
|
||||||
children,
|
children,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
hideProfile = false,
|
hideProfile = false,
|
||||||
noShadow = false,
|
|
||||||
url,
|
url,
|
||||||
...restProps
|
...restProps
|
||||||
}: {
|
}: {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
children: Snippet
|
children?: Snippet
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
hideProfile?: boolean
|
hideProfile?: boolean
|
||||||
noShadow?: boolean
|
|
||||||
url?: string
|
url?: string
|
||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
@ -36,7 +34,7 @@
|
||||||
let muted = $state($isEventMuted(event))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
|
<div class="flex flex-col gap-2 {restProps.class}">
|
||||||
{#if muted}
|
{#if muted}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="row-2 relative">
|
<div class="row-2 relative">
|
||||||
|
|
@ -60,6 +58,6 @@
|
||||||
{formatTimestamp(event.created_at)}
|
{formatTimestamp(event.created_at)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{@render children()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
<div class="card2 card2-interactive col-4">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<Profile {pubkey} {url} />
|
<Profile {pubkey} {url} />
|
||||||
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
showTooltip?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url, showTooltip = true}: Props = $props()
|
||||||
|
|
||||||
const onClick = () => goToSpace(url)
|
const onClick = () => goToSpace(url)
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
href={path}
|
href={path}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
title={$display}
|
title={showTooltip ? $display : ""}
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
notification={$notifications.has(path)}>
|
notification={$notifications.has(path)}>
|
||||||
<RelayIcon {url} size={10} class="rounded-full" />
|
<RelayIcon {url} size={10} class="rounded-full" />
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
|
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.svelte"
|
||||||
import {userSpaceUrls} from "@app/groups"
|
import {userSpaceUrls} from "@app/groups"
|
||||||
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
|
@ -16,6 +18,13 @@
|
||||||
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
||||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||||
|
|
||||||
|
// Tippy mounts its content component once, so pass a stable reactive object it can read from
|
||||||
|
const overflowProps = $state({urls: [] as string[]})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
overflowProps.urls = secondarySpaceUrls
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerHeight={windowHeight} />
|
<svelte:window bind:innerHeight={windowHeight} />
|
||||||
|
|
@ -31,12 +40,24 @@
|
||||||
{#each primarySpaceUrls as url (url)}
|
{#each primarySpaceUrls as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{/each}
|
{/each}
|
||||||
<PrimaryNavItem
|
{#snippet allSpaces(title: string)}
|
||||||
href="/spaces"
|
<PrimaryNavItem
|
||||||
title="All Spaces"
|
href="/spaces"
|
||||||
prefix="no-highlight"
|
{title}
|
||||||
notification={otherSpaceNotifications}>
|
prefix="no-highlight"
|
||||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
notification={otherSpaceNotifications}>
|
||||||
</PrimaryNavItem>
|
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||||
|
</PrimaryNavItem>
|
||||||
|
{/snippet}
|
||||||
|
{#if secondarySpaceUrls.length > 0}
|
||||||
|
<Tippy
|
||||||
|
component={PrimaryNavSpacesOverflow}
|
||||||
|
props={overflowProps}
|
||||||
|
params={{placement: "right", interactive: true}}>
|
||||||
|
{@render allSpaces("")}
|
||||||
|
</Tippy>
|
||||||
|
{:else}
|
||||||
|
{@render allSpaces("All Spaces")}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
16
src/app/components/PrimaryNavSpacesOverflow.svelte
Normal file
16
src/app/components/PrimaryNavSpacesOverflow.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {urls}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex max-h-[80vh] flex-col overflow-y-auto rounded-box border border-solid border-base-content/15 bg-base-200 p-1 shadow-xl">
|
||||||
|
{#each urls as url (url)}
|
||||||
|
<PrimaryNavItemSpace {url} showTooltip={false} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
const maxLength = 5500
|
const maxLength = 5500
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
|
<div class={cx("text-sm", {"card2 card2-sm shadow-none": props.event.kind !== MESSAGE})}>
|
||||||
{#if path && !isMobile}
|
{#if path && !isMobile}
|
||||||
<Link href={path}>
|
<Link href={path}>
|
||||||
<NoteContent {...props} {minLength} {maxLength} />
|
<NoteContent {...props} {minLength} {maxLength} />
|
||||||
|
|
|
||||||
174
src/app/components/RoomSearch.svelte
Normal file
174
src/app/components/RoomSearch.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount, tick} from "svelte"
|
||||||
|
import {get} from "svelte/store"
|
||||||
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
|
import {MESSAGE, sortEventsDesc, displayRelayUrl} from "@welshman/util"
|
||||||
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
|
import {deriveEventsForUrlDesc} from "@app/repository"
|
||||||
|
import {popModal} from "@app/modal"
|
||||||
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
|
let term = $state("")
|
||||||
|
let results = $state<TrustedEvent[]>([])
|
||||||
|
let loading = $state(false)
|
||||||
|
let input: HTMLInputElement | undefined = $state()
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
|
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
|
||||||
|
if (!searchTerm?.trim()) {
|
||||||
|
loading = false
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [MESSAGE, ...CONTENT_KINDS],
|
||||||
|
"#h": [h],
|
||||||
|
search: searchTerm.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
results = get(deriveEventsForUrlDesc(url, [filter]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await load({
|
||||||
|
relays: [url],
|
||||||
|
signal: controller.signal,
|
||||||
|
filters: [filter],
|
||||||
|
})
|
||||||
|
|
||||||
|
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore aborts from superseded searches; surface anything else
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
loading = true
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
doSearch(term, controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
|
const getAgeSection = (createdAt: number) => {
|
||||||
|
const age = now() - createdAt
|
||||||
|
|
||||||
|
if (age <= DAY) {
|
||||||
|
return "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age <= WEEK) {
|
||||||
|
return "week"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "older"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAgeLabel = (createdAt: number) => {
|
||||||
|
const age = now() - createdAt
|
||||||
|
|
||||||
|
if (age < MINUTE) {
|
||||||
|
return "Just now"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age < HOUR) {
|
||||||
|
return `${Math.floor(age / MINUTE)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age < DAY) {
|
||||||
|
return `${Math.floor(age / HOUR)}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.floor(age / DAY)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResultClick = (event: TrustedEvent) => {
|
||||||
|
popModal()
|
||||||
|
goToEvent(event, {keepFocus: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
tick().then(() => input?.focus())
|
||||||
|
|
||||||
|
return () => controller?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal class="col-2">
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Search Content</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon size={4} icon={Magnifier} />
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
bind:value={term}
|
||||||
|
class="min-w-0 grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search this room..."
|
||||||
|
oninput={onInput} />
|
||||||
|
</label>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner {loading}>Searching...</Spinner>
|
||||||
|
{:else if eventsByAge.size === 0 && term}
|
||||||
|
<Spinner {loading}>No results found.</Spinner>
|
||||||
|
{:else}
|
||||||
|
{#each eventsByAge as [key, events] (key)}
|
||||||
|
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||||
|
{#if key === "day"}
|
||||||
|
Last 24 Hours
|
||||||
|
{:else if key === "week"}
|
||||||
|
Last 7 Days
|
||||||
|
{:else}
|
||||||
|
Older
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
<Button
|
||||||
|
class="card2 card2-sm card2-interactive col-2"
|
||||||
|
onclick={() => onResultClick(event)}>
|
||||||
|
<NoteCard minimal {event}>
|
||||||
|
<NoteContentMinimal {event} />
|
||||||
|
</NoteCard>
|
||||||
|
<div class="row-2">
|
||||||
|
<div class="badge badge-sm badge-neutral">
|
||||||
|
{getAgeLabel(event.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
|
||||||
|
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
|
|
@ -96,6 +97,8 @@
|
||||||
|
|
||||||
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
const showActionItems = () => pushModal(SpaceActionItems, {url})
|
||||||
|
|
||||||
|
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||||
|
|
||||||
const canCreateRoom = deriveUserCanCreateRoom(url)
|
const canCreateRoom = deriveUserCanCreateRoom(url)
|
||||||
|
|
||||||
const createInvite = () => pushModal(SpaceInvite, {url})
|
const createInvite = () => pushModal(SpaceInvite, {url})
|
||||||
|
|
@ -248,6 +251,9 @@
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
|
<SecondaryNavItem onclick={openSearch}>
|
||||||
|
<Icon icon={Magnifier} /> Search
|
||||||
|
</SecondaryNavItem>
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
<div class="h-2 shrink-0"></div>
|
<div class="h-2 shrink-0"></div>
|
||||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,66 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick} from "svelte"
|
import {onMount, tick} from "svelte"
|
||||||
|
import {get} from "svelte/store"
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {request} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {repository, tracker} from "@welshman/app"
|
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
import {formatTimestampAsDate, groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
import {MESSAGE, sortEventsDesc} from "@welshman/util"
|
import {MESSAGE, getTagValue, sortEventsDesc, displayRelayUrl} from "@welshman/util"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
|
import {deriveEventsForUrlDesc} from "@app/repository"
|
||||||
|
import {popModal} from "@app/modal"
|
||||||
import {goToEvent} from "@app/routes"
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let show = $state(false)
|
|
||||||
let results = $state<TrustedEvent[]>([])
|
let results = $state<TrustedEvent[]>([])
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let input: HTMLInputElement | undefined = $state()
|
let input: HTMLInputElement | undefined = $state()
|
||||||
let controller: AbortController | undefined
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
const relayStatus = $derived(
|
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
|
||||||
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
if (!searchTerm?.trim()) {
|
||||||
)
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
show = true
|
|
||||||
tick().then(() => input?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
show = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
term = ""
|
|
||||||
show = false
|
|
||||||
loading = false
|
|
||||||
results = []
|
|
||||||
controller?.abort()
|
|
||||||
controller = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRelayUrls = () => [url]
|
|
||||||
|
|
||||||
const getFilter = (searchTerm: string): Filter =>
|
|
||||||
h
|
|
||||||
? {kinds: [MESSAGE, ...CONTENT_KINDS], "#h": [h], search: searchTerm}
|
|
||||||
: {kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm}
|
|
||||||
|
|
||||||
const getLocalResults = (filter: Filter) =>
|
|
||||||
repository.query([filter]).filter(event => tracker.getRelays(event.id).has(url))
|
|
||||||
|
|
||||||
const search = debounce(300, async (searchTerm: string) => {
|
|
||||||
controller?.abort()
|
|
||||||
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
loading = false
|
loading = false
|
||||||
results = []
|
results = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
controller = new AbortController()
|
const filter: Filter = {
|
||||||
loading = true
|
kinds: [MESSAGE, ...CONTENT_KINDS],
|
||||||
|
search: searchTerm.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
const filter = getFilter(searchTerm.trim())
|
results = get(deriveEventsForUrlDesc(url, [filter]))
|
||||||
const localResults = getLocalResults(filter)
|
|
||||||
|
|
||||||
results = sortEventsDesc(localResults)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const events = await request({
|
const events = await load({
|
||||||
relays: getRelayUrls(),
|
relays: [url],
|
||||||
autoClose: true,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [filter],
|
filters: [filter],
|
||||||
})
|
})
|
||||||
|
|
||||||
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...localResults]))
|
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Ignore aborts from superseded searches; surface anything else
|
||||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
results = sortEventsDesc(localResults)
|
throw error
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
|
|
@ -96,7 +68,10 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
void search(term)
|
loading = true
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
doSearch(term, controller)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
@ -133,80 +108,72 @@
|
||||||
return `${Math.floor(age / DAY)}d ago`
|
return `${Math.floor(age / DAY)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoomSearchResultClick = (event: TrustedEvent) => {
|
const onResultClick = (event: TrustedEvent) => {
|
||||||
close()
|
popModal()
|
||||||
goToEvent(event, {keepFocus: true})
|
goToEvent(event, {keepFocus: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
tick().then(() => input?.focus())
|
||||||
|
|
||||||
|
return () => controller?.abort()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
<Modal class="col-2">
|
||||||
<Icon size={4} icon={Magnifier} />
|
<ModalHeader>
|
||||||
</button>
|
<ModalTitle>Search Content</ModalTitle>
|
||||||
{#if show}
|
<ModalSubtitle>
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||||
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
</ModalSubtitle>
|
||||||
<div
|
</ModalHeader>
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
<ModalBody>
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
<div class="flex justify-between">
|
<Icon size={4} icon={Magnifier} />
|
||||||
<strong>Search</strong>
|
<input
|
||||||
<Button onclick={clear}>
|
bind:this={input}
|
||||||
<Icon icon={CloseCircle} />
|
bind:value={term}
|
||||||
</Button>
|
class="min-w-0 grow"
|
||||||
</div>
|
type="text"
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
placeholder="Search this space..."
|
||||||
<Icon size={4} icon={Magnifier} />
|
oninput={onInput} />
|
||||||
<input
|
</label>
|
||||||
bind:this={input}
|
{#if loading}
|
||||||
bind:value={term}
|
<Spinner {loading}>Searching...</Spinner>
|
||||||
class="min-w-0 grow"
|
{:else if eventsByAge.size === 0 && term}
|
||||||
type="text"
|
<Spinner {loading}>No results found.</Spinner>
|
||||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
{:else}
|
||||||
oninput={onInput} />
|
{#each eventsByAge as [key, events] (key)}
|
||||||
</label>
|
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
{#if key === "day"}
|
||||||
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
Last 24 Hours
|
||||||
{#if !term}
|
{:else if key === "week"}
|
||||||
<p class="text-sm opacity-70">
|
Last 7 Days
|
||||||
{h ? "Search for content in this room." : "Search for content in this space."}
|
{:else}
|
||||||
</p>
|
Older
|
||||||
{:else if loading}
|
{/if}
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
</p>
|
||||||
{:else if eventsByAge.size === 0}
|
{#each events as event (event.id)}
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
{@const h = getTagValue("h", event.tags)}
|
||||||
{:else}
|
<Button
|
||||||
<div class="col-2">
|
class="card2 card2-sm card2-interactive col-2"
|
||||||
{#each eventsByAge as [key, events] (key)}
|
onclick={() => onResultClick(event)}>
|
||||||
<div class="col-2">
|
<NoteCard minimal {event}>
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
<NoteContentMinimal {event} />
|
||||||
{#if key === "day"}
|
</NoteCard>
|
||||||
Last 24 Hours
|
<div class="row-2">
|
||||||
{:else if key === "week"}
|
<div class="badge badge-sm badge-neutral">
|
||||||
Last 7 Days
|
{getAgeLabel(event.created_at)}
|
||||||
{:else}
|
|
||||||
Older
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="col-2">
|
|
||||||
{#each events as event (event.id)}
|
|
||||||
<button
|
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
|
||||||
onclick={() => onRoomSearchResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() || "(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{#if h}
|
||||||
</div>
|
<div class="badge badge-sm badge-neutral">
|
||||||
{/if}
|
<RoomName {url} {h} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
style?: string
|
style?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
"data-tip"?: string
|
"data-tip"?: string
|
||||||
|
"aria-label"?: string
|
||||||
"aria-pressed"?: boolean
|
"aria-pressed"?: boolean
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@
|
||||||
const {...props}: Props = $props()
|
const {...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="content-padding-t content-padding-x flex h-full flex-col gap-1 {props.class}">
|
<div class="col-1 h-full {props.class}">
|
||||||
<div class="z-feature">
|
<div class="z-feature">
|
||||||
<div class="content-sizing">
|
<div class="content-sizing">
|
||||||
{@render props.input?.()}
|
{@render props.input?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="scroll-container content-sizing h-full overflow-auto pt-2">
|
<div class="scroll-container content-sizing h-full pt-2">
|
||||||
{@render props.content?.()}
|
{@render props.content?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
children?: import("svelte").Snippet
|
children?: import("svelte").Snippet
|
||||||
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const {loading = false, children}: Props = $props()
|
const {loading = false, children, ...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="flex min-h-10 items-center">
|
<span class="flex min-h-10 items-center justify-center {props.class}">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<span class="pr-3" transition:slide|local={{axis: "x"}}>
|
<span class="pr-3" transition:slide|local={{axis: "x"}}>
|
||||||
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
|
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Page from "@lib/components/Page.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
import ContentSearch from "@lib/components/ContentSearch.svelte"
|
||||||
import PeopleItem from "@app/components/PeopleItem.svelte"
|
import PeopleItem from "@app/components/PeopleItem.svelte"
|
||||||
import {bootstrapPubkeys} from "@app/social"
|
import {bootstrapPubkeys} from "@app/social"
|
||||||
|
|
@ -38,25 +39,27 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<ContentSearch>
|
<PageContent class="col-2 p-2 sm:col-4 sm:p-4">
|
||||||
{#snippet input()}
|
<ContentSearch>
|
||||||
<label class="row-2 input input-bordered w-full">
|
{#snippet input()}
|
||||||
<Icon icon={Magnifier} />
|
<label class="row-2 input input-bordered w-full">
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<Icon icon={Magnifier} />
|
||||||
<input
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
autofocus={!isMobile}
|
<input
|
||||||
bind:value={term}
|
autofocus={!isMobile}
|
||||||
class="grow"
|
bind:value={term}
|
||||||
type="text"
|
class="grow"
|
||||||
placeholder="Search for people..." />
|
type="text"
|
||||||
</label>
|
placeholder="Search for people..." />
|
||||||
{/snippet}
|
</label>
|
||||||
{#snippet content()}
|
{/snippet}
|
||||||
<div class="col-2 h-full" bind:this={element}>
|
{#snippet content()}
|
||||||
{#each pubkeys.slice(0, limit) as pubkey (pubkey)}
|
<div class="col-2 h-full" bind:this={element}>
|
||||||
<PeopleItem {pubkey} />
|
{#each pubkeys.slice(0, limit) as pubkey (pubkey)}
|
||||||
{/each}
|
<PeopleItem {pubkey} />
|
||||||
</div>
|
{/each}
|
||||||
{/snippet}
|
</div>
|
||||||
</ContentSearch>
|
{/snippet}
|
||||||
|
</ContentSearch>
|
||||||
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, tick} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {flip} from "svelte/animate"
|
import {flip} from "svelte/animate"
|
||||||
import {cubicOut} from "svelte/easing"
|
import {cubicOut} from "svelte/easing"
|
||||||
import {derived as _derived} from "svelte/store"
|
import {derived as _derived} from "svelte/store"
|
||||||
|
|
@ -8,13 +8,12 @@
|
||||||
import {ROOMS} from "@welshman/util"
|
import {ROOMS} from "@welshman/util"
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import {pull, relays, createSearch} from "@welshman/app"
|
import {pull, relays, createSearch} from "@welshman/app"
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller, isMobile} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
|
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
|
||||||
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
import Widget from "@assets/icons/widget-4.svg?dataurl"
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.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"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Page from "@lib/components/Page.svelte"
|
import Page from "@lib/components/Page.svelte"
|
||||||
|
|
@ -22,6 +21,7 @@
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.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 ContentSearch from "@lib/components/ContentSearch.svelte"
|
||||||
import RelaySummary from "@app/components/RelaySummary.svelte"
|
import RelaySummary from "@app/components/RelaySummary.svelte"
|
||||||
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
import SpaceAdd from "@app/components/SpaceAdd.svelte"
|
||||||
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
|
||||||
|
|
@ -154,8 +154,6 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let showSearch = $state(false)
|
|
||||||
let searchInput: HTMLInputElement | undefined = $state()
|
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element
|
let element: Element
|
||||||
let orderedSpaceUrls = $state<string[]>([])
|
let orderedSpaceUrls = $state<string[]>([])
|
||||||
|
|
@ -164,16 +162,6 @@
|
||||||
let lastDragTarget = $state<string | undefined>()
|
let lastDragTarget = $state<string | undefined>()
|
||||||
let didDrop = $state(false)
|
let didDrop = $state(false)
|
||||||
|
|
||||||
const openSearch = () => {
|
|
||||||
showSearch = true
|
|
||||||
tick().then(() => searchInput?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSearch = () => {
|
|
||||||
showSearch = false
|
|
||||||
term = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const inviteData = $derived(parseInviteLink(term))
|
const inviteData = $derived(parseInviteLink(term))
|
||||||
const searchResults = $derived($relaySearch.searchOptions(term))
|
const searchResults = $derived($relaySearch.searchOptions(term))
|
||||||
const userSpaceSet = $derived(new Set($userSpaceUrls))
|
const userSpaceSet = $derived(new Set($userSpaceUrls))
|
||||||
|
|
@ -213,32 +201,6 @@
|
||||||
<strong>Spaces</strong>
|
<strong>Spaces</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
</button>
|
|
||||||
{#if showSearch}
|
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
|
|
||||||
></button>
|
|
||||||
<div class="fixed top-sai right-sai left-content-full z-feature p-2">
|
|
||||||
<div
|
|
||||||
class="card2 card2-sm p-2! bg-alt flex flex-col shadow-md"
|
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
<input
|
|
||||||
bind:this={searchInput}
|
|
||||||
bind:value={term}
|
|
||||||
class="min-w-0 grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search for spaces..."
|
|
||||||
onkeydown={e => e.key === "Escape" && closeSearch()} />
|
|
||||||
<Button onclick={closeSearch} class="flex items-center">
|
|
||||||
<Icon icon={CloseCircle} />
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if PLATFORM_RELAYS.length === 0}
|
{#if PLATFORM_RELAYS.length === 0}
|
||||||
<Button class="btn btn-primary btn-sm" onclick={addSpace}>
|
<Button class="btn btn-primary btn-sm" onclick={addSpace}>
|
||||||
<Icon icon={AddCircle} />
|
<Icon icon={AddCircle} />
|
||||||
|
|
@ -248,82 +210,98 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 p-4">
|
<PageContent class="col-2 p-2 sm:col-4 sm:p-4">
|
||||||
<div class="flex flex-col gap-2" bind:this={element}>
|
<ContentSearch>
|
||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#snippet input()}
|
||||||
<Button
|
<label class="row-2 input input-bordered w-full">
|
||||||
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
|
<Icon icon={Magnifier} />
|
||||||
onclick={() => openSpace(url)}>
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<RelaySummary {url} />
|
<input
|
||||||
</Button>
|
autofocus={!isMobile}
|
||||||
{:else}
|
bind:value={term}
|
||||||
{#await loadUserGroupList()}
|
class="min-w-0 grow"
|
||||||
<div class="flex items-center justify-center py-20">
|
type="text"
|
||||||
<span class="loading loading-spinner mr-3"></span>
|
placeholder="Search for spaces..." />
|
||||||
Loading your spaces...
|
</label>
|
||||||
</div>
|
{/snippet}
|
||||||
{:then}
|
{#snippet content()}
|
||||||
{#if inviteData}
|
<div class="col-2" bind:this={element}>
|
||||||
<Divider>Search results</Divider>
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
{#key inviteData.url}
|
<Button class="card2 card2-interactive" onclick={() => openSpace(url)}>
|
||||||
<Button
|
<RelaySummary {url} />
|
||||||
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
|
|
||||||
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
|
|
||||||
<RelaySummary url={inviteData.url} />
|
|
||||||
</Button>
|
|
||||||
{/key}
|
|
||||||
{/if}
|
|
||||||
{#if filteredUserUrls.length > 0}
|
|
||||||
<Divider>Your spaces</Divider>
|
|
||||||
{#each filteredUserUrls as url (url)}
|
|
||||||
<div
|
|
||||||
animate:flip={{duration: 300, easing: cubicOut}}
|
|
||||||
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
|
|
||||||
draggable="true"
|
|
||||||
role="listitem"
|
|
||||||
ondragstart={e => onDragStart(e, url)}
|
|
||||||
ondragover={onDragOver}
|
|
||||||
ondragenter={e => onDragEnter(e, url)}
|
|
||||||
ondrop={e => onDrop(e, url)}
|
|
||||||
ondragend={onDragEnd}>
|
|
||||||
<Button
|
|
||||||
class="group card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative min-w-0"
|
|
||||||
onclick={() => openSpace(url)}>
|
|
||||||
<div class="flex w-full items-start gap-2">
|
|
||||||
<div
|
|
||||||
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
|
|
||||||
<Icon icon={DragHandle} />
|
|
||||||
</div>
|
|
||||||
<RelaySummary hideFavorites {url} />
|
|
||||||
</div>
|
|
||||||
{#if $notifications.has(makeSpacePath(url))}
|
|
||||||
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if !term}
|
|
||||||
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
|
|
||||||
{/if}
|
|
||||||
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
|
|
||||||
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
|
||||||
<Button
|
|
||||||
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
|
|
||||||
onclick={() => openSpace(relay.url)}>
|
|
||||||
<RelaySummary url={relay.url} />
|
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{:else}
|
||||||
<div class="flex justify-center py-20">
|
{#await loadUserGroupList()}
|
||||||
{#await sleep(5000)}
|
<div class="flex items-center justify-center py-20">
|
||||||
<Spinner loading>Looking for spaces...</Spinner>
|
<span class="loading loading-spinner mr-3"></span>
|
||||||
|
Loading your spaces...
|
||||||
|
</div>
|
||||||
{:then}
|
{:then}
|
||||||
{#if otherSpaces.length === 0}
|
{#if inviteData}
|
||||||
<Spinner>No other spaces found.</Spinner>
|
<Divider>Search results</Divider>
|
||||||
|
{#key inviteData.url}
|
||||||
|
<Button
|
||||||
|
class="card2 card2-interactive"
|
||||||
|
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
|
||||||
|
<RelaySummary url={inviteData.url} />
|
||||||
|
</Button>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if filteredUserUrls.length > 0}
|
||||||
|
<Divider>Your spaces</Divider>
|
||||||
|
{#each filteredUserUrls as url (url)}
|
||||||
|
<div
|
||||||
|
animate:flip={{duration: 300, easing: cubicOut}}
|
||||||
|
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
|
||||||
|
draggable="true"
|
||||||
|
role="listitem"
|
||||||
|
ondragstart={e => onDragStart(e, url)}
|
||||||
|
ondragover={onDragOver}
|
||||||
|
ondragenter={e => onDragEnter(e, url)}
|
||||||
|
ondrop={e => onDrop(e, url)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<Button
|
||||||
|
class="group card2 card2-interactive w-full relative min-w-0"
|
||||||
|
onclick={() => openSpace(url)}>
|
||||||
|
<div class="flex w-full items-start gap-2">
|
||||||
|
<div
|
||||||
|
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
|
||||||
|
<Icon icon={DragHandle} />
|
||||||
|
</div>
|
||||||
|
<RelaySummary hideFavorites {url} />
|
||||||
|
</div>
|
||||||
|
{#if $notifications.has(makeSpacePath(url))}
|
||||||
|
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else if !term}
|
||||||
|
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
|
||||||
|
{/if}
|
||||||
|
{#if otherSpaces.length > 0}
|
||||||
|
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
|
||||||
|
{/if}
|
||||||
|
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
|
||||||
|
<Button
|
||||||
|
class="card2 card2-interactive"
|
||||||
|
onclick={() => openSpace(relay.url)}>
|
||||||
|
<RelaySummary url={relay.url} />
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
<div class="flex justify-center py-20">
|
||||||
|
{#await sleep(5000)}
|
||||||
|
<Spinner loading>Looking for spaces...</Spinner>
|
||||||
|
{:then}
|
||||||
|
{#if otherSpaces.length === 0}
|
||||||
|
<Spinner>No other spaces found.</Spinner>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
{/each}
|
||||||
{/await}
|
</div>
|
||||||
{/each}
|
{/snippet}
|
||||||
</div>
|
</ContentSearch>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||||
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -28,7 +29,7 @@
|
||||||
import RoomDetail from "@app/components/RoomDetail.svelte"
|
import RoomDetail from "@app/components/RoomDetail.svelte"
|
||||||
import RoomItem from "@app/components/RoomItem.svelte"
|
import RoomItem from "@app/components/RoomItem.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
import RoomSearch from "@app/components/RoomSearch.svelte"
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||||
|
|
@ -424,7 +425,12 @@
|
||||||
<RoomName {url} {h} />
|
<RoomName {url} {h} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<SpaceSearch {url} {h} />
|
<Button
|
||||||
|
class="btn btn-neutral btn-sm btn-square"
|
||||||
|
aria-label="Search"
|
||||||
|
onclick={() => pushModal(RoomSearch, {url, h})}>
|
||||||
|
<Icon size={4} icon={Magnifier} />
|
||||||
|
</Button>
|
||||||
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
|
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
|
||||||
<Icon size={4} icon={InfoCircle} />
|
<Icon size={4} icon={InfoCircle} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -483,7 +489,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if loadingForward}
|
{#if loadingForward && elements.length > 0}
|
||||||
<p class="py-20 flex justify-center">
|
<p class="py-20 flex justify-center">
|
||||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
|
import Magnifier from "@assets/icons/magnifier.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 Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
|
@ -32,6 +33,7 @@
|
||||||
import {publishDelete} from "@app/deletes"
|
import {publishDelete} from "@app/deletes"
|
||||||
import {checked} from "@app/notifications"
|
import {checked} from "@app/notifications"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
import {makeFeed} from "@app/feeds"
|
import {makeFeed} from "@app/feeds"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
|
|
||||||
|
|
@ -308,12 +310,17 @@
|
||||||
<strong>Chat</strong>
|
<strong>Chat</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<SpaceSearch {url} />
|
<Button
|
||||||
|
class="btn btn-neutral btn-sm btn-square"
|
||||||
|
aria-label="Search"
|
||||||
|
onclick={() => pushModal(SpaceSearch, {url})}>
|
||||||
|
<Icon size={4} icon={Magnifier} />
|
||||||
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex-col-reverse !mb-0">
|
<PageContent bind:element onscroll={onScroll} class="flex-col-reverse !mb-0">
|
||||||
{#if loadingForward}
|
{#if loadingForward && elements.length > 0}
|
||||||
<p class="py-20 flex justify-center">
|
<p class="py-20 flex justify-center">
|
||||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick, onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {debounce} from "throttle-debounce"
|
|
||||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK, uniqBy} from "@welshman/lib"
|
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {MESSAGE, getTagValue, sortEventsDesc} from "@welshman/util"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import History from "@assets/icons/history.svg?dataurl"
|
import History from "@assets/icons/history.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
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 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 RecentItem from "@app/components/RecentItem.svelte"
|
import RecentItem from "@app/components/RecentItem.svelte"
|
||||||
|
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
|
||||||
import {deriveRecentActivity} from "@app/recent"
|
import {deriveRecentActivity} from "@app/recent"
|
||||||
import {goToEvent} from "@app/routes"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
|
|
||||||
const recentActivity = deriveRecentActivity(url)
|
const recentActivity = deriveRecentActivity(url)
|
||||||
|
|
||||||
let term = $state("")
|
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||||
let showSearch = $state(false)
|
|
||||||
let loading = $state(false)
|
|
||||||
let searchResults: TrustedEvent[] = $state([])
|
|
||||||
let searchInput: HTMLInputElement | undefined = $state()
|
|
||||||
let controller: AbortController | undefined
|
|
||||||
|
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element | undefined = $state()
|
let element: Element | undefined = $state()
|
||||||
|
|
||||||
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
|
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
|
||||||
const age = now() - createdAt
|
|
||||||
|
|
||||||
if (age <= DAY) return "day"
|
|
||||||
if (age <= WEEK) return "week"
|
|
||||||
return "older"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAgeLabel = (createdAt: number) => {
|
|
||||||
const age = now() - createdAt
|
|
||||||
|
|
||||||
if (age < MINUTE) return "Just now"
|
|
||||||
if (age < HOUR) return `${Math.floor(age / MINUTE)}m ago`
|
|
||||||
if (age < DAY) return `${Math.floor(age / HOUR)}h ago`
|
|
||||||
return `${Math.floor(age / DAY)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSearch = () => {
|
|
||||||
showSearch = true
|
|
||||||
tick().then(() => searchInput?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSearch = () => {
|
|
||||||
showSearch = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
term = ""
|
|
||||||
showSearch = false
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
controller?.abort()
|
|
||||||
controller = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = debounce(300, async (searchTerm: string) => {
|
|
||||||
controller?.abort()
|
|
||||||
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
controller = new AbortController()
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await request({
|
|
||||||
relays: [url],
|
|
||||||
autoClose: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm.trim()}],
|
|
||||||
})
|
|
||||||
|
|
||||||
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
||||||
searchResults = []
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onInput = () => {
|
|
||||||
showSearch = true
|
|
||||||
void search(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onResultClick = (event: TrustedEvent) => {
|
|
||||||
closeSearch()
|
|
||||||
goToEvent(event, {keepFocus: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const scroller = createScroller({
|
const scroller = createScroller({
|
||||||
element: element!,
|
element: element!,
|
||||||
|
|
@ -132,77 +43,9 @@
|
||||||
<strong>Recent Activity</strong>
|
<strong>Recent Activity</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
<Button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
||||||
<Icon size={4} icon={Magnifier} />
|
<Icon size={4} icon={Magnifier} />
|
||||||
</button>
|
</Button>
|
||||||
{#if showSearch}
|
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
|
|
||||||
></button>
|
|
||||||
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
|
||||||
<div
|
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<strong>Search</strong>
|
|
||||||
<Button onclick={clearSearch}>
|
|
||||||
<Icon icon={CloseCircle} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
<input
|
|
||||||
bind:this={searchInput}
|
|
||||||
bind:value={term}
|
|
||||||
class="min-w-0 grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search this space..."
|
|
||||||
oninput={onInput} />
|
|
||||||
</label>
|
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
|
||||||
{#if !term}
|
|
||||||
<p class="text-sm opacity-70">Search for content across this space.</p>
|
|
||||||
{:else if loading}
|
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
|
||||||
{:else if resultsByAge.size === 0}
|
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="col-2">
|
|
||||||
{#each resultsByAge as [key, events] (key)}
|
|
||||||
<div class="col-2">
|
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
|
||||||
{#if key === "day"}
|
|
||||||
Last 24 Hours
|
|
||||||
{:else if key === "week"}
|
|
||||||
Last 7 Days
|
|
||||||
{:else}
|
|
||||||
Older
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="col-2">
|
|
||||||
{#each events as event (event.id)}
|
|
||||||
<button
|
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
|
||||||
onclick={() => onResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() ||
|
|
||||||
getTagValue("title", event.tags) ||
|
|
||||||
"(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue