Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Convert node sidebar to a header
Archived rudolfs opened 23 days ago

This converts the node sidebar to a header to be consistent with layouts we use in other places in the app.

  • Show all external addresses and copy full node connect command
  • Show helper popover to instruct users on how to configure external addresses
  • Always show full node id
  • Remove node banner image
  • Fix broken seeding policy explainer

check check-visual check-unit-test check-http-client-unit-test check-radicle-httpd check-e2e check-build check-http 👉 Preview 👉 Workflow runs 👉 Branch on GitHub

7 files changed +389 -186 21dc8a62 0857b8d8
modified src/components/Id.svelte
@@ -32,6 +32,7 @@

  let visible: boolean = false;
  export let debounceTimeout = 50;
+
  export let tooltipPosition: "above" | "below" = "above";

  const setVisible = debounce((value: boolean) => {
    visible = value;
@@ -94,7 +95,9 @@
  </div>

  {#if visible}
-
    <div style:position="absolute" style:top="-2rem">
+
    <div
+
      style:position="absolute"
+
      style:top={tooltipPosition === "below" ? "2rem" : "-2rem"}>
      <div class="popover">
        <Icon name={icon} />
        {tooltip}
modified src/components/ScopePolicyExplainer.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
  import type { DefaultSeedingPolicy, SeedingPolicy } from "@http-client";

-
  import capitalize from "lodash/capitalize";
-

  export let seedingPolicy: DefaultSeedingPolicy | SeedingPolicy;

-
  $: [policy, scope] = Object.values(seedingPolicy);
+
  $: policy =
+
    "default" in seedingPolicy ? seedingPolicy.default : seedingPolicy.policy;
+
  $: scope = "scope" in seedingPolicy ? seedingPolicy.scope : undefined;
</script>

<style>
@@ -19,7 +19,9 @@

<div class="section" style:padding-top="0.5rem">
  Policy:
-
  <span class="txt-body-m-semibold">{capitalize(policy)}</span>
+
  <span class="txt-body-m-semibold" style:text-transform="capitalize">
+
    {policy}
+
  </span>
</div>
<div class="text" style:color="var(--color-text-tertiary)">
  {#if policy === "allow"}
@@ -29,10 +31,12 @@
  {/if}
</div>

-
{#if policy === "allow"}
+
{#if policy === "allow" && scope}
  <div class="section" style:padding-top="0.5rem">
    Scope:
-
    <span class="txt-body-m-semibold">{capitalize(scope)}</span>
+
    <span class="txt-body-m-semibold" style:text-transform="capitalize">
+
      {scope}
+
    </span>
  </div>
  <div class="text" style:color="var(--color-text-tertiary)">
    {#if scope === "all"}
modified src/components/UserAvatar.svelte
@@ -4,9 +4,10 @@
  interface Props {
    nodeId: string;
    styleWidth: string;
+
    styleHeight?: string;
  }

-
  const { nodeId, styleWidth }: Props = $props();
+
  const { nodeId, styleWidth, styleHeight }: Props = $props();

  let dataUri: string | undefined = $state(undefined);

@@ -18,5 +19,9 @@
</script>

{#if dataUri}
-
  <img style:width={styleWidth} src={dataUri} alt="Avatar" />
+
  <img
+
    style:width={styleWidth}
+
    style:height={styleHeight}
+
    src={dataUri}
+
    alt="Avatar" />
{/if}
modified src/views/nodes/NodeAddress.svelte
@@ -1,23 +1,111 @@
<script lang="ts">
  import type { Node } from "@http-client";

-
  import { truncateId } from "@app/lib/utils";
+
  import Command from "@app/components/Command.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
  import Id from "@app/components/Id.svelte";
+
  import Popover from "@app/components/Popover.svelte";

  export let node: Node;

-
  $: clipboard = node.config?.externalAddresses
-
    ? `${node.id}@${node.config.externalAddresses[0]}`
-
    : node.id;
+
  $: addresses = node.config?.externalAddresses ?? [];
+

+
  const externalAddressesCommand =
+
    'rad config push node.externalAddresses "example.com:58776"';
</script>

-
<div style:word-break="break-word">
-
  <!--prettier-ignore-->
-
  <Id ariaLabel="node-id" shorten={false} id={clipboard}>
-
    {#if node.config?.externalAddresses.length}
-
      {truncateId(node.id)}@<wbr />{node.config?.externalAddresses[0]}
-
    {:else}
-
      {truncateId(node.id)}
-
    {/if}
-
  </Id>
-
</div>
+
<style>
+
  .item {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
    width: 100%;
+
    font: var(--txt-body-m-regular);
+
  }
+
  .address-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .box {
+
    font: var(--txt-body-m-regular);
+
    line-height: 1.625rem;
+
    width: 20rem;
+
  }
+
</style>
+

+
{#if addresses.length === 0}
+
  <div class="item">
+
    <span style:white-space="nowrap">External Address</span>
+
    <div
+
      class="global-flex-item"
+
      style:align-items="center"
+
      style:gap="0.25rem"
+
      style:color="var(--color-text-tertiary)"
+
      style:font="var(--txt-body-m-regular)">
+
      Not configured.
+
      <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
+
        <IconButton slot="toggle" let:toggle on:click={toggle}>
+
          <Icon name="guide" />
+
        </IconButton>
+
        <div slot="popover" class="box">
+
          If you're the owner of this node, you can set an external address by
+
          running:
+
          <div style:margin-top="1rem">
+
            <Command command={externalAddressesCommand} fullWidth />
+
          </div>
+
        </div>
+
      </Popover>
+
    </div>
+
  </div>
+
{:else if addresses.length === 1}
+
  <div class="item">
+
    <span style:white-space="nowrap">External Address</span>
+
    <Id
+
      ariaLabel="external-address"
+
      id={`rad node connect ${node.id}@${addresses[0]}`}
+
      shorten={false}>
+
      <span
+
        class="txt-overflow"
+
        style:font="var(--txt-code-regular)"
+
        style:margin-right="2.25rem">
+
        {addresses[0]}
+
      </span>
+
    </Id>
+
  </div>
+
{:else if addresses.length > 1}
+
  <div class="item">
+
    <span style:white-space="nowrap">External Addresses</span>
+
    <Popover popoverPositionTop="2rem" popoverPositionRight="0">
+
      <div
+
        slot="toggle"
+
        let:toggle
+
        let:expanded
+
        style:display="flex"
+
        style:align-items="center"
+
        style:gap="0.25rem">
+
        <Id
+
          ariaLabel="external-address"
+
          id={`rad node connect ${node.id}@${addresses[0]}`}
+
          shorten={false}>
+
          <span style:font="var(--txt-code-regular)">{addresses[0]}</span>
+
        </Id>
+
        <IconButton on:click={toggle}>
+
          <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
        </IconButton>
+
      </div>
+
      <div slot="popover" class="address-list">
+
        {#each addresses as address}
+
          <Id
+
            ariaLabel="external-address"
+
            id={`rad node connect ${node.id}@${address}`}
+
            shorten={false}>
+
            <span style:font="var(--txt-code-regular)">{address}</span>
+
          </Id>
+
        {/each}
+
      </div>
+
    </Popover>
+
  </div>
+
{/if}
added src/views/nodes/NodeHeader.svelte
@@ -0,0 +1,230 @@
+
<script lang="ts">
+
  import type { BaseUrl, Node, NodeStats } from "@http-client";
+

+
  import dompurify from "dompurify";
+
  import { markdown } from "@app/lib/markdown";
+
  import { truncateId } from "@app/lib/utils";
+

+
  import Command from "@app/components/Command.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import UserAvatar from "@app/components/UserAvatar.svelte";
+

+
  import Id from "@app/components/Id.svelte";
+
  import NodeAddress from "./NodeAddress.svelte";
+
  import PolicyExplainer from "./PolicyExplainer.svelte";
+
  import Seeding from "./Seeding.svelte";
+
  import SeedSelector from "./SeedSelector.svelte";
+
  import UserAgent from "./UserAgent.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let node: Node;
+
  export let stats: NodeStats;
+

+
  let headerHeight: number = 0;
+

+
  function render(content: string): string {
+
    return dompurify.sanitize(
+
      markdown({ linkify: true, emojis: true }).parseInline(content) as string,
+
    );
+
  }
+

+
  const descriptionCommand = `rad config set web.description "My node description"`;
+
</script>
+

+
<style>
+
  .header-layout {
+
    display: flex;
+
    gap: 1rem;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
  }
+
  .avatar {
+
    flex-shrink: 0;
+
    line-height: 0;
+
  }
+
  .avatar img,
+
  .avatar :global(img) {
+
    width: auto;
+
    display: block;
+
  }
+
  .meta {
+
    flex: 1;
+
    min-width: 0;
+
    padding: 1rem 0;
+
    align-self: flex-start;
+
  }
+
  .info {
+
    flex: 1;
+
    min-width: 0;
+
    padding: 1rem;
+
    border-left: 1px solid var(--color-border-subtle);
+
    display: flex;
+
    align-items: flex-start;
+
  }
+
  .title {
+
    display: flex;
+
    align-items: center;
+
    font: var(--txt-heading-l);
+
  }
+
  .description {
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-tertiary);
+
    display: -webkit-box;
+
    -webkit-line-clamp: 3;
+
    line-clamp: 3;
+
    -webkit-box-orient: vertical;
+
    overflow: hidden;
+
    word-break: break-word;
+
  }
+
  .description :global(a) {
+
    border-bottom: 1px solid var(--color-text-tertiary);
+
  }
+
  .description :global(a:hover) {
+
    border-bottom: 1px solid var(--color-text-primary);
+
  }
+
  .item {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
    width: 100%;
+
    font: var(--txt-body-m-regular);
+
  }
+
  .info-items {
+
    display: flex;
+
    flex-direction: column;
+
    width: 100%;
+
  }
+
  .info-item {
+
    display: flex;
+
    align-items: center;
+
    height: 2rem;
+
    width: 100%;
+
  }
+
  code {
+
    font: var(--txt-code-regular);
+
    background-color: var(--color-surface-mid);
+
    border-radius: var(--border-radius-sm);
+
    padding: 0.125rem 0.25rem;
+
  }
+
  .box {
+
    font: var(--txt-body-m-regular);
+
    line-height: 1.625rem;
+
    width: 20rem;
+
  }
+

+
  @media (max-width: 719.98px) {
+
    .header-layout {
+
      flex-wrap: wrap;
+
      padding: 1rem;
+
    }
+
    .avatar :global(img) {
+
      width: 4rem !important;
+
      height: 4rem !important;
+
    }
+
    .meta {
+
      flex-basis: 0;
+
      padding: 0;
+
    }
+
    .info {
+
      flex-basis: 100%;
+
      padding: 0;
+
      border-left: none;
+
      border-top: 1px solid var(--color-border-subtle);
+
      padding-top: 0.5rem;
+
    }
+
  }
+
</style>
+

+
<div class="header-layout">
+
  <div class="avatar">
+
    {#if node.avatarUrl}
+
      <img
+
        style:border-radius="var(--border-radius-md)"
+
        style:height={headerHeight > 0 ? `${headerHeight}px` : undefined}
+
        style:max-height="12rem"
+
        alt="Node avatar"
+
        src={node.avatarUrl} />
+
    {:else}
+
      <UserAvatar
+
        nodeId={node.id}
+
        styleWidth="auto"
+
        styleHeight={headerHeight > 0 ? `${headerHeight}px` : undefined} />
+
    {/if}
+
  </div>
+
  <div class="meta">
+
    <div class="title">
+
      <SeedSelector {baseUrl} />
+
    </div>
+
    {#if node.description}
+
      <div class="description">
+
        {@html render(node.description)}
+
      </div>
+
    {:else}
+
      <div
+
        class="global-flex-item"
+
        style:align-items="center"
+
        style:gap="0.25rem"
+
        style:color="var(--color-text-tertiary)"
+
        style:font="var(--txt-body-m-regular)">
+
        No description configured.
+
        <Popover popoverPositionTop="2.5rem" popoverPositionRight="-3.5rem">
+
          <IconButton slot="toggle" let:toggle on:click={toggle}>
+
            <Icon name="guide" />
+
          </IconButton>
+
          <div slot="popover" class="box">
+
            If you're the owner of this node, you can customize this by setting
+
            the
+
            <code>description</code>
+
            field in your node config.
+
            <div style:margin-top="1rem">
+
              <Command command={descriptionCommand} fullWidth />
+
            </div>
+
          </div>
+
        </Popover>
+
      </div>
+
    {/if}
+
  </div>
+
  <div class="info" bind:clientHeight={headerHeight}>
+
    <div class="info-items">
+
      <div class="info-item">
+
        <div class="item">
+
          <span style:white-space="nowrap">Node ID</span>
+
          <Id
+
            ariaLabel="node-id"
+
            id={node.id}
+
            shorten={false}
+
            tooltipPosition="below">
+
            <span
+
              class="txt-overflow global-hide-on-medium-desktop-down"
+
              style:font="var(--txt-code-regular)"
+
              style:margin-right="2.25rem">
+
              {node.id}
+
            </span>
+
            <span
+
              class="txt-overflow global-hide-on-desktop-up"
+
              style:font="var(--txt-code-regular)"
+
              style:margin-right="2.25rem">
+
              {truncateId(node.id)}
+
            </span>
+
          </Id>
+
        </div>
+
      </div>
+
      <div class="info-item">
+
        <NodeAddress {node} />
+
      </div>
+
      <div class="info-item">
+
        <PolicyExplainer seedingPolicy={node.config?.seedingPolicy} />
+
      </div>
+
      <div class="info-item">
+
        <Seeding count={stats.repos.total}>
+
          <div style:width="2rem"></div>
+
        </Seeding>
+
      </div>
+
      <div class="info-item">
+
        <UserAgent agent={node.agent} />
+
      </div>
+
    </div>
+
  </div>
+
</div>
modified src/views/nodes/PolicyExplainer.svelte
@@ -1,55 +1,47 @@
<script lang="ts">
  import type { DefaultSeedingPolicy } from "@http-client";

-
  import capitalize from "lodash/capitalize";
-

-
  import IconButton from "@app/components/IconButton.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import Popover from "@app/components/Popover.svelte";
  import ScopePolicyExplainer from "@app/components/ScopePolicyExplainer.svelte";

  export let seedingPolicy: DefaultSeedingPolicy | undefined = undefined;

-
  let expandedNode = false;
-

  $: shortScope =
-
    seedingPolicy?.default === "allow" && seedingPolicy?.scope === "all"
-
      ? "permissive"
-
      : "restrictive";
+
    seedingPolicy?.default === "allow" ? "permissive" : "restrictive";
</script>

<style>
-
  .policies {
-
    font: var(--txt-body-m-regular);
-
    display: flex;
-
    flex-direction: column;
-
  }
  .item {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
+
    font: var(--txt-body-m-regular);
+
    width: 100%;
+
  }
+
  .popover-content {
+
    width: 16rem;
  }
</style>

-
<div class="policies">
-
  <div class="item">
-
    <div class="item" style="justify-content: flex-start;">
-
      <span class="no-wrap">Seeding Policy</span>
-
    </div>
-
    <div
-
      style="display: flex; flex-direction: row; gap: 0.5rem; align-items: center;">
-
      <div class="txt-body-m-semibold">
-
        {capitalize(shortScope)}
-
      </div>
-
      <IconButton on:click={() => (expandedNode = !expandedNode)}>
-
        <Icon name={`chevron-${expandedNode ? "up" : "down"}`} />
-
      </IconButton>
-
    </div>
+
<div class="item">
+
  <span class="no-wrap">Seeding Policy</span>
+
  <div style:display="flex" style:align-items="center" style:gap="0.5rem">
+
    <span class="txt-body-m-semibold" style:text-transform="capitalize">
+
      {shortScope}
+
    </span>
+
    {#if seedingPolicy}
+
      <Popover popoverPositionTop="2rem" popoverPositionRight="0">
+
        <IconButton slot="toggle" let:toggle let:expanded on:click={toggle}>
+
          <Icon name={expanded ? "chevron-up" : "chevron-down"} />
+
        </IconButton>
+
        <div slot="popover" class="popover-content">
+
          <ScopePolicyExplainer {seedingPolicy} />
+
        </div>
+
      </Popover>
+
    {/if}
  </div>
-
  {#if expandedNode && seedingPolicy}
-
    <div style:padding-bottom="1rem">
-
      <ScopePolicyExplainer {seedingPolicy} />
-
    </div>
-
  {/if}
</div>
modified src/views/nodes/View.svelte
@@ -1,150 +1,31 @@
<script lang="ts">
  import type { BaseUrl, Node, NodeStats } from "@http-client";

-
  import dompurify from "dompurify";
-
  import { markdown } from "@app/lib/markdown";
-

-
  import Command from "@app/components/Command.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import Layout from "@app/components/Layout.svelte";
-
  import Popover from "@app/components/Popover.svelte";
+
  import Header from "@app/components/Header.svelte";
+
  import NodeHeader from "./NodeHeader.svelte";
  import ReposView from "./ReposView.svelte";
-
  import UserAvatar from "@app/components/UserAvatar.svelte";
-

-
  import PolicyExplainer from "./PolicyExplainer.svelte";
-
  import SeedSelector from "./SeedSelector.svelte";
-
  import Seeding from "./Seeding.svelte";
-
  import UserAgent from "./UserAgent.svelte";
-
  import NodeAddress from "./NodeAddress.svelte";

  export let baseUrl: BaseUrl;
  export let stats: NodeStats;
  export let node: Node;
-

-
  function render(content: string): string {
-
    return dompurify.sanitize(
-
      markdown({ linkify: true, emojis: true }).parse(content) as string,
-
    );
-
  }
</script>

<style>
-
  .sidebar {
-
    padding: 1rem;
-
  }
-

-
  .sidebar-content {
-
    gap: 1rem;
+
  .layout {
    display: flex;
    flex-direction: column;
+
    height: 100%;
  }
-

-
  .description {
-
    word-break: break-word;
-
  }
-

-
  .sidebar-item {
-
    display: flex;
-
    align-items: center;
-
    height: 2rem;
-
  }
-

-
  .box {
-
    font: var(--txt-body-m-regular);
-
    line-height: 1.625rem;
-
    width: 17rem;
-
  }
-

-
  code {
-
    font: var(--txt-code-regular);
-
    background-color: var(--color-surface-mid);
-
    border-radius: var(--border-radius-sm);
-
    padding: 0.125rem 0.25rem;
+
  .content {
+
    overflow-y: auto;
+
    flex: 1;
  }
</style>

-
<Layout>
-
  <div slot="sidebar">
-
    {#if node.bannerUrl}
-
      <img style:width="100%" alt="Node banner" src={node.bannerUrl} />
-
    {/if}
-

-
    <div class="sidebar">
-
      <div class="sidebar-content">
-
        <div style:display="flex" style:align-items="center" style:gap="1rem">
-
          {#if node.avatarUrl}
-
            <img
-
              style:border-radius="var(--border-radius-md)"
-
              style:min-width="64px"
-
              width="64"
-
              height="64"
-
              class="avatar"
-
              alt="Seed avatar"
-
              src={node.avatarUrl} />
-
          {:else}
-
            <UserAvatar nodeId={node.id} styleWidth="4rem" />
-
          {/if}
-
          <div style:width="100%">
-
            <div class="global-flex-item">
-
              <SeedSelector {baseUrl} />
-
            </div>
-
            <NodeAddress {node} />
-
          </div>
-
        </div>
-

-
        {#if node.description}
-
          <div class="description txt-body-m-regular">
-
            {@html render(node.description)}
-
          </div>
-
        {:else}
-
          <div
-
            class="global-flex-item txt-body-m-regular"
-
            style:align-items="center"
-
            style:justify-content="space-between"
-
            style:color="var(--color-text-tertiary)"
-
            style:gap="0.25rem">
-
            No description configured.
-
            <Popover popoverPositionTop="0" popoverPositionLeft="2.25rem">
-
              <IconButton slot="toggle" let:toggle on:click={toggle}>
-
                <Icon name="guide" />
-
              </IconButton>
-

-
              <div slot="popover" class="box">
-
                If you're the owner of this node, you can customize this page by
-
                setting the
-
                <code>avatarUrl</code>
-
                ,
-
                <code>bannerUrl</code>
-
                and
-
                <code>description</code>
-
                fields under the
-
                <code>web</code>
-
                object in your node config.
-
                <div style:margin-top="1rem">
-
                  <Command command="rad config edit" fullWidth />
-
                </div>
-
              </div>
-
            </Popover>
-
          </div>
-
        {/if}
-

-
        <div style:display="flex" style:flex-direction="column">
-
          <PolicyExplainer seedingPolicy={node.config?.seedingPolicy} />
-
          <div class="sidebar-item">
-
            <Seeding count={stats.repos.total}>
-
              <div style:width="2rem"></div>
-
            </Seeding>
-
          </div>
-
          <div class="sidebar-item">
-
            <UserAgent agent={node.agent} />
-
          </div>
-
        </div>
-
      </div>
-
    </div>
-
  </div>
-

-
  <div slot="center">
+
<div class="layout">
+
  <Header />
+
  <div class="content">
+
    <NodeHeader {baseUrl} {node} {stats} />
    <ReposView {baseUrl} {stats} />
  </div>
-
</Layout>
+
</div>