Lay the home dashboard out as full-width sections

This commit is contained in:
Coracle-Bot 2026-09-08 23:30:23 +00:00 committed by hodlbod
parent d3ceaac1b9
commit c4934db5ff
10 changed files with 178 additions and 130 deletions

View file

@ -2,7 +2,6 @@
import Stars from "@assets/icons/stars.svg?dataurl" import Stars from "@assets/icons/stars.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 Card from "@lib/components/Card.svelte"
import type {HealthCheck} from "@app/healthChecks" import type {HealthCheck} from "@app/healthChecks"
import {healthChecks} from "@app/healthChecks" import {healthChecks} from "@app/healthChecks"
@ -15,13 +14,13 @@
const apply = () => $healthChecks.apply(healthCheck) const apply = () => $healthChecks.apply(healthCheck)
</script> </script>
<Card sm class="bg-surface flex justify-between"> <div class="flex items-start justify-between gap-3 px-4 py-3">
<div class="flex flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<strong>{healthCheck.title}</strong> <strong class="text-sm">{healthCheck.title}</strong>
<p class="text-sm">{healthCheck.description}</p> <p class="text-sm opacity-75">{healthCheck.description}</p>
</div> </div>
<Button class="button button-neutral button-sm" onclick={apply}> <Button class="button button-neutral button-sm shrink-0" onclick={apply}>
<Icon icon={Stars} /> <Icon icon={Stars} size={4} />
{healthCheck.action} {healthCheck.action}
</Button> </Button>
</Card> </div>

View file

@ -5,6 +5,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Badge from "@lib/components/Badge.svelte" import Badge from "@lib/components/Badge.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import HomeSection from "@app/components/HomeSection.svelte"
import HealthCheckItem from "@app/components/HealthCheckItem.svelte" import HealthCheckItem from "@app/components/HealthCheckItem.svelte"
import {healthChecks} from "@app/healthChecks" import {healthChecks} from "@app/healthChecks"
@ -17,30 +18,30 @@
} }
</script> </script>
<div class="card flex flex-col gap-3"> <HomeSection title="Health checks" icon={Stethoscope}>
<div class="flex items-center justify-between gap-3"> {#snippet action()}
<strong class="flex items-center gap-2 text-lg">
<Icon icon={Stethoscope} />
Health checks
</strong>
{#if $pending.length > 0} {#if $pending.length > 0}
<Badge variant="warning">{$pending.length} open</Badge> <Badge variant="warning">{$pending.length} open</Badge>
{/if} {/if}
</div> {/snippet}
{#if $pending.length === 0} {#if $pending.length === 0}
<p class="flex items-center gap-2 text-sm opacity-75"> <p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75">
<Icon icon={CheckCircle} size={4} /> <Icon icon={CheckCircle} size={4} />
Your connection to the network looks healthy. Your connection to the network looks healthy.
</p> </p>
{:else} {:else}
{#each $pending as healthCheck (healthCheck.title)} <div class="flex flex-col divide-y divide-line border-t border-line">
<HealthCheckItem {healthCheck} /> {#each $pending as healthCheck (healthCheck.title)}
{/each} <HealthCheckItem {healthCheck} />
{#if $pending.length > 1} {/each}
<Button class="button button-primary button-sm" onclick={applyAll}> {#if $pending.length > 1}
<Icon icon={Stars} size={4} /> <div class="flex justify-center p-3">
Apply all recommendations <Button class="button button-primary button-sm" onclick={applyAll}>
</Button> <Icon icon={Stars} size={4} />
{/if} Apply all recommendations
</Button>
</div>
{/if}
</div>
{/if} {/if}
</div> </HomeSection>

View file

@ -5,6 +5,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import HomeSection from "@app/components/HomeSection.svelte"
import RelayListItem from "@app/components/hosting/RelayListItem.svelte" import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
import RelayCreate from "@app/components/hosting/RelayCreate.svelte" import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
import {user} from "@app/core" import {user} from "@app/core"
@ -31,30 +32,32 @@
</script> </script>
{#if loaded} {#if loaded}
<div class="card flex flex-col gap-3"> <HomeSection title="Hosting" icon={Server}>
<div class="flex items-center justify-between gap-3"> {#snippet action()}
<strong class="flex items-center gap-2 text-lg">
<Icon icon={Server} />
Hosting
</strong>
{#if relays.length > 0} {#if relays.length > 0}
<Link href="/settings/hosting" class="button button-neutral button-xs">Manage</Link> <Link href="/settings/hosting" class="button button-neutral button-xs">Manage</Link>
{/if} {/if}
{/snippet}
{#if relays.length > 0}
<div class="flex flex-col divide-y divide-line border-y border-line">
{#each relays as relay (relay.id)}
<RelayListItem {relay} class="px-4 py-3" />
{/each}
</div>
{/if}
<div class="flex flex-col items-start gap-3 px-4 pt-3 pb-4">
<p class="text-sm opacity-75">
{#if relays.length > 0}
Spin up another hosted space — rooms, calendar and moderation included.
{:else}
Don't wait for an invite. Flotilla Hosting sets up a space with rooms, calendar and
moderation in about a minute.
{/if}
</p>
<Button class="button button-primary button-sm" onclick={openCreate}>
<Icon icon={Add} size={4} />
{relays.length > 0 ? "Add another space" : "Start a space"}
</Button>
</div> </div>
{#each relays as relay (relay.id)} </HomeSection>
<RelayListItem {relay} />
{/each}
<p class="text-sm opacity-75">
{#if relays.length > 0}
Spin up another hosted space — rooms, calendar and moderation included.
{:else}
Don't wait for an invite. Flotilla Hosting sets up a space with rooms, calendar and
moderation in about a minute.
{/if}
</p>
<Button class="button button-primary button-sm" onclick={openCreate}>
<Icon icon={Add} size={4} />
{relays.length > 0 ? "Add another space" : "Start a space"}
</Button>
</div>
{/if} {/if}

View file

@ -5,7 +5,9 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import HomeSection from "@app/components/HomeSection.svelte"
import HomeInboxItem from "@app/components/HomeInboxItem.svelte" import HomeInboxItem from "@app/components/HomeInboxItem.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import {displayContentCount} from "@app/content" import {displayContentCount} from "@app/content"
import {inboxConversations, inboxSpaceContent} from "@app/inbox" import {inboxConversations, inboxSpaceContent} from "@app/inbox"
@ -27,18 +29,14 @@
const startChat = () => navigate("/chat") const startChat = () => navigate("/chat")
</script> </script>
<div class="card flex flex-col gap-3"> <HomeSection title="Inbox" icon={Inbox}>
<div class="flex items-center justify-between gap-3"> {#snippet action()}
<strong class="flex items-center gap-2 text-lg">
<Icon icon={Inbox} />
Inbox
</strong>
{#if hasUnread} {#if hasUnread}
<Button class="button button-neutral button-xs" onclick={markAllRead}>Mark all read</Button> <Button class="button button-neutral button-xs" onclick={markAllRead}>Mark all read</Button>
{/if} {/if}
</div> {/snippet}
{#if conversations.length === 0} {#if conversations.length === 0 && $inboxSpaceContent.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center"> <div class="flex flex-col items-center gap-3 px-4 pb-8 text-center">
<p class="font-medium">Nothing in your inbox yet</p> <p class="font-medium">Nothing in your inbox yet</p>
<p class="max-w-md text-sm opacity-75"> <p class="max-w-md text-sm opacity-75">
Rooms and spaces you belong to report their activity here — messages, threads, classifieds, Rooms and spaces you belong to report their activity here — messages, threads, classifieds,
@ -56,16 +54,19 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-2"> <div class="flex flex-col divide-y divide-line border-t border-line">
{#each conversations as conversation (conversation.path)} {#each conversations as conversation (conversation.path)}
<HomeInboxItem {conversation} /> <HomeInboxItem {conversation} />
{/each} {/each}
{#each $inboxSpaceContent as { url, countsByKind } (url)}
<Link
href={makeSpacePath(url)}
class="flex items-center gap-3 px-4 py-3 text-sm hover:bg-surface-more">
<RelayIcon {url} size={6} class="shrink-0" />
<strong class="truncate"><RelayName {url} /></strong>
<span class="truncate opacity-75">{displayContent(countsByKind)}</span>
</Link>
{/each}
</div> </div>
{/if} {/if}
{#each $inboxSpaceContent as { url, countsByKind } (url)} </HomeSection>
<Link href={makeSpacePath(url)} class="flex flex-wrap items-baseline gap-x-2 text-sm">
<strong><RelayName {url} /></strong>
<span class="opacity-75">{displayContent(countsByKind)}</span>
</Link>
{/each}
</div>

View file

@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import {formatTimestamp, remove, uniq} from "@welshman/lib" import {formatTimestamp, remove, uniq} from "@welshman/lib"
import ChatRoundDots from "@assets/icons/chat-round-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte" import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
@ -25,23 +24,22 @@
const others = $derived(uniq(remove($user.pubkey, conversation.pubkeys ?? []))) const others = $derived(uniq(remove($user.pubkey, conversation.pubkeys ?? [])))
</script> </script>
<Link href={path} class="card card-sm card-interactive flex flex-col gap-2"> <Link href={path} class="flex items-center gap-3 px-4 py-3 hover:bg-surface-more">
<div class="flex min-w-0 items-center gap-2 text-sm"> {#if url}
{#if url} <RelayIcon {url} size={9} class="shrink-0" />
{:else}
<ProfileCircle pubkey={others[0] || $user.pubkey} size={9} class="shrink-0" />
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex min-w-0 items-baseline gap-2 text-sm">
<strong class="truncate"> <strong class="truncate">
{#if h} {#if url}
<RoomName {url} {h} /> {#if h}
{:else} <RoomName {url} {h} />
Chat {:else}
{/if} Chat
</strong> {/if}
<span class="truncate opacity-50"> {:else if others.length === 0}
<RelayName {url} />
</span>
{:else}
<Icon icon={ChatRoundDots} size={4} class="shrink-0 opacity-50" />
<strong class="truncate">
{#if others.length === 0}
Note to self Note to self
{:else} {:else}
<ProfileName pubkey={others[0]} /> <ProfileName pubkey={others[0]} />
@ -50,24 +48,29 @@
{/if} {/if}
{/if} {/if}
</strong> </strong>
<span class="truncate opacity-50">direct message</span> <span class="truncate opacity-50">
{/if} {#if url}
<span class="ml-auto flex shrink-0 items-center gap-2 text-xs opacity-50"> <RelayName {url} />
{formatTimestamp(event.created_at)} {:else}
<UnreadDot {path} /> direct message
</span> {/if}
</div> </span>
<div class="flex min-w-0 items-center gap-2"> </div>
<ProfileCircle pubkey={event.pubkey} size={5} /> <div class="flex min-w-0 items-center gap-1 text-sm opacity-75">
<span class="shrink-0 text-xs opacity-50"> <span class="shrink-0">
{#if event.pubkey === $user.pubkey} {#if event.pubkey === $user.pubkey}
You: You:
{:else} {:else}
<ProfileName pubkey={event.pubkey} {url} />: <ProfileName pubkey={event.pubkey} {url} />:
{/if} {/if}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<NoteContentMinimal {event} {url} singleLine /> <NoteContentMinimal {event} {url} singleLine />
</div>
</div> </div>
</div> </div>
<div class="flex shrink-0 items-center gap-2 text-xs opacity-50">
{formatTimestamp(event.created_at)}
<UnreadDot {path} />
</div>
</Link> </Link>

View file

@ -7,10 +7,10 @@
import {Feeds} from "@welshman/app" import {Feeds} from "@welshman/app"
import {Scope, feedFromFilter, makeIntersectionFeed, makeScopeFeed} from "@welshman/feeds" import {Scope, feedFromFilter, makeIntersectionFeed, makeScopeFeed} from "@welshman/feeds"
import Planet from "@assets/icons/planet.svg?dataurl" import Planet from "@assets/icons/planet.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import HomeSection from "@app/components/HomeSection.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import NoteItem from "@app/components/NoteItem.svelte"
import {app, relayLists, user} from "@app/core" import {app, relayLists, user} from "@app/core"
import {makeFeedContext} from "@app/feeds" import {makeFeedContext} from "@app/feeds"
@ -60,18 +60,14 @@
onMount(load) onMount(load)
</script> </script>
<div class="card flex flex-col gap-3"> <HomeSection title="Network" icon={Planet}>
<strong class="flex items-center gap-2 text-lg">
<Icon icon={Planet} />
Network
</strong>
{#if events.length === 0} {#if events.length === 0}
{#if loading} {#if loading}
<div class="flex justify-center py-8"> <div class="flex justify-center px-4 pb-8">
<Spinner {loading}>Looking for notes from people you follow…</Spinner> <Spinner {loading}>Looking for notes from people you follow…</Spinner>
</div> </div>
{:else} {:else}
<div class="flex flex-col items-center gap-3 py-8 text-center"> <div class="flex flex-col items-center gap-3 px-4 pb-8 text-center">
<p class="font-medium">Follow a few people to fill this out</p> <p class="font-medium">Follow a few people to fill this out</p>
<p class="max-w-md text-sm opacity-75"> <p class="max-w-md text-sm opacity-75">
Notes from the people you follow collect here. Spaces are a good place to find some. Notes from the people you follow collect here. Spaces are a good place to find some.
@ -80,11 +76,13 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="flex flex-col gap-2"> <div class="flex flex-col divide-y divide-line border-t border-line">
{#each events.slice(0, limit) as event (event.id)} {#each events.slice(0, limit) as event (event.id)}
<NoteItem {event} {context} /> <NoteItem {event} {context} class="cv px-4 py-3" />
{/each} {/each}
</div> </div>
<Button class="button button-neutral button-sm" onclick={showMore}>Show more</Button> <div class="flex justify-center border-t border-line p-3">
<Button class="button button-neutral button-sm" onclick={showMore}>Show more</Button>
</div>
{/if} {/if}
</div> </HomeSection>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type {Snippet} from "svelte"
import Icon from "@lib/components/Icon.svelte"
type Props = {
title: string
icon: string
action?: Snippet
children: Snippet
}
const {title, icon, action, children}: Props = $props()
</script>
<section class="flex min-w-0 flex-col border-b border-line last:border-b-0">
<div class="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
<h2 class="flex items-center gap-2 text-xs font-bold tracking-widest uppercase opacity-75">
<Icon {icon} size={4} />
{title}
</h2>
{@render action?.()}
</div>
{@render children()}
</section>

View file

@ -19,9 +19,16 @@
children?: Snippet children?: Snippet
context: FeedContext context: FeedContext
url?: string url?: string
class?: string
} }
const {url, event, children, context}: Props = $props() const {
url,
event,
children,
context,
class: className = "card card-interactive",
}: Props = $props()
const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)])) const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)]))
@ -34,7 +41,7 @@
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []}) const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
</script> </script>
<Cv tag={NoteCard} {event} {url} class="card card-interactive"> <Cv tag={NoteCard} {event} {url} class={className}>
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary <ReactionSummary

View file

@ -9,16 +9,17 @@
type Props = { type Props = {
relay: HostedRelay relay: HostedRelay
class?: string
} }
const {relay}: Props = $props() const {relay, class: className = "card p-3 sm:p-4"}: Props = $props()
const name = $derived(relay.info_name || relay.subdomain) const name = $derived(relay.info_name || relay.subdomain)
const host = $derived(canonicalRelayHost(relay)) const host = $derived(canonicalRelayHost(relay))
const href = $derived(makeSpacePath(getHostedRelayUrl(relay), "admin")) const href = $derived(makeSpacePath(getHostedRelayUrl(relay), "admin"))
</script> </script>
<div class="card flex flex-row items-center justify-between gap-3 p-3 sm:p-4"> <div class="flex flex-row items-center justify-between gap-3 {className}">
<ImageIcon size={8} alt="" class="rounded-xl" src={relay.info_icon || Server} /> <ImageIcon size={8} alt="" class="rounded-xl" src={relay.info_icon || Server} />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{name}</p> <p class="truncate text-sm font-medium">{name}</p>

View file

@ -13,6 +13,12 @@
import {HOSTING_ENABLED} from "@app/hosting" import {HOSTING_ENABLED} from "@app/hosting"
import {PLATFORM_RELAYS} from "@app/env" import {PLATFORM_RELAYS} from "@app/env"
const today = new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "short",
day: "numeric",
})
// A single-space build has no dashboard to show - everything on it is scoped to the one space. // A single-space build has no dashboard to show - everything on it is scoped to the one space.
onMount(() => { onMount(() => {
if (PLATFORM_RELAYS.length > 0) { if (PLATFORM_RELAYS.length > 0) {
@ -23,21 +29,26 @@
<Page> <Page>
<PageBar> <PageBar>
<div class="flex items-center gap-2"> <div class="flex items-center justify-between gap-2">
<Icon icon={Home} size={6} /> <div class="flex items-center gap-2">
<strong>Home</strong> <Icon icon={Home} size={6} />
<strong>Home</strong>
</div>
<span class="hidden text-sm opacity-75 sm:inline">{today}</span>
</div> </div>
</PageBar> </PageBar>
<PageContent class="flex flex-col gap-4 p-2 sm:p-4 lg:flex-row lg:items-start"> <PageContent noPad class="flex flex-col">
<div class="flex min-w-0 flex-col gap-4 lg:flex-[2]"> <div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
<HomeInbox /> <div class="flex min-w-0 flex-col bg-surface lg:flex-[2] lg:border-r lg:border-line">
<HomeNetwork /> <HomeInbox />
</div> <HomeNetwork />
<div class="flex min-w-0 flex-col gap-4 lg:flex-1"> </div>
<HomeHealthChecks /> <div class="flex min-w-0 flex-col border-t border-line lg:w-80 lg:shrink-0 lg:border-t-0">
{#if HOSTING_ENABLED} <HomeHealthChecks />
<HomeHosting /> {#if HOSTING_ENABLED}
{/if} <HomeHosting />
{/if}
</div>
</div> </div>
</PageContent> </PageContent>
</Page> </Page>