feat: redesign mobile space sidebar with card-based layout. (#318)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-07-07 21:23:52 +00:00 committed by hodlbod
parent 7011d0ac81
commit f4fc9b82c3
6 changed files with 370 additions and 188 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {derived} from "svelte/store"
import cx from "classnames"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
import {fly} from "@lib/transition"
@ -59,7 +60,12 @@
import {makeSpacePath, goToChat} from "@app/routes"
import {notifications} from "@app/notifications"
const {url} = $props()
type Props = {
url: string
mobile?: boolean
}
const {url, mobile = false}: Props = $props()
const relay = deriveRelay(url)
const chatPath = makeSpacePath(url, "chat")
@ -136,187 +142,239 @@
let element: Element | undefined = $state()
</script>
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col pb-0">
<div class="shrink-0">
<Button
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface"
onclick={openMenu}>
<div class="flex items-center justify-between">
<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}>
</div>
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
{#snippet spaceHeader()}
<Button
class={cx(
mobile
? "space-menu__header-button text-content"
: "relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-surface",
)}
onclick={openMenu}>
<div class="flex items-center justify-between">
<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}>
</div>
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
<li>
<Button onclick={createInvite}>
<Icon icon={LinkRound} />
Create Invite
</Button>
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
</Button>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li>
<Button onclick={contactOwner}>
<Icon icon={Letter} />
Contact Owner
</Button>
</li>
{/if}
<li>
{#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button>
{:else}
<Link href="/settings/alerts">
<Icon icon={Bell} />
Enable notifications
</Link>
{/if}
</li>
<li>
{#if $userSpaceUrls.includes(url)}
<Button onclick={leaveSpace} class="text-error">
<Icon icon={Exit} />
Leave Space
</Button>
{:else}
<Button
onclick={joinSpace}
class="bg-primary text-primary-content"
style="color: var(--primary-content)">
<Icon icon={Login} />
Join Space
</Button>
{/if}
</li>
</ul>
</Popover>
{/if}
{#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" />
{/if}
</strong>
<Icon icon={AltArrowDown} />
</div>
<div class="flex min-h-0 flex-1 flex-col gap-1 py-1 overflow-auto">
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full gap-1 rounded-2xl bg-surface p-2">
<li>
<Button onclick={createInvite}>
<Icon icon={LinkRound} />
Create Invite
</Button>
</li>
{#if $userIsAdmin}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
</Button>
</li>
{/if}
{#if $relay?.pubkey && $relay.pubkey !== $pubkey}
<li>
<Button onclick={contactOwner}>
<Icon icon={Letter} />
Contact Owner
</Button>
</li>
{/if}
<li>
{#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button>
{:else}
<Link href="/settings/alerts">
<Icon icon={Bell} />
Enable notifications
</Link>
{/if}
</li>
<li>
{#if $userSpaceUrls.includes(url)}
<Button onclick={leaveSpace} class="text-error">
<Icon icon={Exit} />
Leave Space
</Button>
{:else}
<Button
onclick={joinSpace}
class="bg-primary text-primary-content"
style="color: var(--primary-content)">
<Icon icon={Login} />
Join Space
</Button>
{/if}
</li>
</ul>
</Popover>
{/if}
{/snippet}
{#snippet spaceNavItems()}
<SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details
</SecondaryNavItem>
{#if hasNip29($relay)}
<SecondaryNavItem href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
{/if}
{/snippet}
{#snippet spaceRooms()}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(THREAD)}
<SecondaryNavItem href={threadsPath}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath}>
<Icon icon={CaseMinimalistic} /> Classifieds
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem href={calendarPath}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(POLL)}
<SecondaryNavItem href={pollsPath}>
<Icon icon={Revote} /> Polls
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
<div class="h-2 shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2 shrink-0"></div>
<SecondaryNavHeader>
{#if $userRooms.length > 0}
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#if $otherRooms.length > 20}
<label class="input input-sm flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
Other Rooms
{:else}
Rooms
{/if}
</SecondaryNavHeader>
{/if}
{#if $otherRooms.length > 20}
<label class="input input-sm flex items-center gap-2">
<Icon icon={Magnifier} />
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{#if $otherVoiceRooms.length > 0}
{#if !mobile}
<div class="h-2 shrink-0"></div>
{/if}
<div class="h-5 shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {url} {h} tooltip={!mobile} />
{/each}
{/if}
{#if $canCreateRoom}
<SecondaryNavItem onclick={addRoom}>
<Icon icon={AddCircle} />
Create room
</SecondaryNavItem>
{/if}
{/if}
{/snippet}
{#snippet spaceFooter()}
<VoiceWidget />
<Link
href={makeSpacePath(url, "about")}
class={cx(mobile ? "space-menu__status-link" : "button button-neutral button-sm h-10")}>
<SocketStatusIndicator {url} />
</Link>
{/snippet}
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
{#if mobile}
<SecondaryNavSection class="space-menu min-h-0 flex-1 flex flex-col gap-3 p-0">
<div class="card space-menu__card space-menu__header shrink-0">
{@render spaceHeader()}
</div>
<div class="space-menu__scroll flex flex-col">
<div class="card space-menu__card flex flex-col gap-1">
<SecondaryNavHeader>Space</SecondaryNavHeader>
{@render spaceNavItems()}
</div>
{#if hasNip29($relay) && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
<div class="card space-menu__card flex flex-col gap-1">
{@render spaceRooms()}
</div>
{/if}
</div>
<div class="card space-menu__card space-menu__status shrink-0">
{@render spaceFooter()}
</div>
</SecondaryNavSection>
{:else}
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col pb-0">
<div class="shrink-0">
{@render spaceHeader()}
</div>
<div class="flex min-h-0 flex-1 flex-col gap-1 py-1 overflow-auto">
{@render spaceNavItems()}
{@render spaceRooms()}
<div class="h-5 shrink-0"></div>
</div>
</SecondaryNavSection>
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
{@render spaceFooter()}
</div>
</SecondaryNavSection>
<div class="flex shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-1 md:pb-2 z-nav">
<VoiceWidget />
<Link href={makeSpacePath(url, "about")} class="button button-neutral button-sm h-10">
<SocketStatusIndicator {url} />
</Link>
</div>
{/if}
</div>

View file

@ -10,13 +10,14 @@
import {notifications} from "@app/notifications"
import {makeRoomPath} from "@app/routes"
interface Props {
url: any
h: any
type Props = {
url: string
h: string
replaceState?: boolean
tooltip?: boolean
}
const {url, h, replaceState = false}: Props = $props()
const {url, h, replaceState = false, tooltip = true}: Props = $props()
const room = deriveRoom(url, h)
const roomType = $derived(getRoomType($room))
@ -31,7 +32,7 @@
{#if roomType === RoomType.Voice}
<VoiceRoomItem {url} {h} {replaceState} {notification} />
{:else}
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
<SecondaryNavItem href={path} title={tooltip ? roomName : ""} {replaceState} {notification}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="ml-auto opacity-50" />

View file

@ -6,6 +6,7 @@
const {children}: Props = $props()
</script>
<div class="flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
<div
class="secondary-nav__header flex items-center justify-between px-1 py-2 text-sm font-bold uppercase">
{@render children?.()}
</div>

View file

@ -0,0 +1,125 @@
@layer components {
@media (max-width: 768px) {
.secondary-nav--mobile-space {
@apply mt-sai bg-backdrop mb-0 w-full max-w-full grow border-0;
.tip[data-tip]::before {
display: none !important;
}
.secondary-nav__nav-item {
@apply rounded-2xl font-medium;
&:hover {
@apply bg-surface-less text-content;
transform: none;
box-shadow: none;
}
&:active {
transform: none;
box-shadow: none;
}
}
.secondary-nav__nav-item--active {
@apply text-content;
background-color: color-mix(in srgb, var(--primary) 14%, var(--surface));
box-shadow: none;
&:hover {
@apply text-content;
background-color: color-mix(in srgb, var(--primary) 18%, var(--surface));
}
}
}
.space-menu {
@apply gap-3 p-3 pt-2;
}
.space-menu__card {
@apply p-2;
}
.space-menu__card .secondary-nav__header {
@apply text-content-muted px-2 py-1 text-xs font-semibold tracking-wider uppercase;
}
.space-menu__card .secondary-nav__header:first-child {
@apply text-content pb-0.5 font-bold;
}
.space-menu__card .secondary-nav__header:not(:first-child) {
@apply border-line-less mt-2 border-t pt-3;
}
.space-menu__header {
@apply p-3;
}
.space-menu__header-button {
@apply rounded-2xl p-0;
&:hover {
@apply bg-transparent;
}
}
.space-menu__scroll {
@apply min-h-0 flex-1 gap-3 overflow-y-auto;
}
.space-menu__status {
@apply flex w-full flex-col gap-2;
}
.space-menu__status > .card {
@apply w-full gap-1 border-0 bg-transparent p-0 shadow-none;
}
.space-menu__status-link {
@apply h-auto w-full border-0 bg-transparent p-0 shadow-none;
&:hover {
@apply text-content border-0 bg-transparent;
}
}
.space-menu__status-link .flex {
@apply justify-start px-1;
}
}
}
@media (max-width: 768px) {
[data-theme="dark"] .secondary-nav--mobile-space {
@apply bg-surface-less;
}
[data-fl-theme="clay"] .secondary-nav--mobile-space {
.secondary-nav__nav-item:hover,
.secondary-nav__nav-item:active {
background-color: var(--surface-less);
transform: none;
box-shadow: none;
}
.secondary-nav__nav-item--active,
.secondary-nav__nav-item--active:hover {
background-color: color-mix(in srgb, var(--primary) 14%, var(--surface));
color: var(--content);
box-shadow: none;
}
}
[data-fl-theme="clay"] .space-menu__status-link:hover,
[data-fl-theme="clay"] .space-menu__status-link:active {
transform: none;
box-shadow: none;
}
.space-menu__status .text-sm {
@apply font-medium;
}
}

View file

@ -36,6 +36,7 @@
@import "./room.css";
@import "./spinner.css";
@import "./secondary-nav.css";
@import "./space-menu.css";
@import "./tooltip.css";
@config "../../../tailwind.config.js";

View file

@ -4,7 +4,6 @@
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import {decodeRelay} from "@app/relays"
import {goToSpace} from "@app/routes"
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte"
const url = decodeRelay($page.params.relay!)
@ -22,10 +21,7 @@
<svelte:window bind:innerWidth={width} />
{#if width <= md}
<div class="primary-nav">
<PrimaryNavSpaces />
</div>
<SecondaryNav visible class="w-auto grow pb-16">
<SpaceMenu {url} />
<SecondaryNav visible class="secondary-nav--mobile-space w-auto grow pb-16">
<SpaceMenu {url} mobile />
</SecondaryNav>
{/if}