Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
feat: new homepage
Merged did:key:z6MkwdzD...LXno opened 2 years ago
35 files changed +1171 -485 3a293a98 b1411ae4
modified package-lock.json
@@ -8,6 +8,7 @@
      "version": "1.0.0",
      "hasInstallScript": true,
      "dependencies": {
+
        "@efstajas/svelte-stored-writable": "^0.2.0",
        "@radicle/gray-matter": "4.1.0",
        "@wooorm/starry-night": "^3.2.0",
        "async-mutex": "^0.4.1",
@@ -84,6 +85,23 @@
        "node": ">=6.0.0"
      }
    },
+
    "node_modules/@efstajas/svelte-stored-writable": {
+
      "version": "0.2.0",
+
      "resolved": "https://registry.npmjs.org/@efstajas/svelte-stored-writable/-/svelte-stored-writable-0.2.0.tgz",
+
      "integrity": "sha512-z04mzHHe8HNgteQRJqbfq/omTEFCl+BHMLt1P0Mu1PcnNvnT5fhGTRG0oBd1D8HkRxu4Do+pQO5YrrdIwKluRg==",
+
      "dependencies": {
+
        "svelte": "^3.57.0",
+
        "zod": "^3.21.4"
+
      }
+
    },
+
    "node_modules/@efstajas/svelte-stored-writable/node_modules/svelte": {
+
      "version": "3.59.2",
+
      "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz",
+
      "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==",
+
      "engines": {
+
        "node": ">= 8"
+
      }
+
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.19.12",
      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
modified package.json
@@ -49,6 +49,7 @@
    "wait-on": "^7.2.0"
  },
  "dependencies": {
+
    "@efstajas/svelte-stored-writable": "^0.2.0",
    "@radicle/gray-matter": "4.1.0",
    "@wooorm/starry-night": "^3.2.0",
    "async-mutex": "^0.4.1",
modified src/App/Header.svelte
@@ -6,11 +6,11 @@
  import Authenticate from "./Header/Authenticate.svelte";
  import Breadcrumbs from "./Header/Breadcrumbs.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeInfo from "@app/App/Header/NodeInfo.svelte";
  import Popover from "@app/components/Popover.svelte";
+
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";

  const buttonTitle: Record<HttpdState["state"], string> = {
    stopped: "radicle-httpd is stopped",
@@ -39,26 +39,9 @@
    height: var(--button-regular-height);
    margin: 0 0.5rem;
  }
-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
  .connect-popover {
    max-width: 20rem;
  }
-
  .divider {
-
    height: 1px;
-
    width: 100%;
-
    background-color: var(--color-fill-separator);
-
    margin: 1rem 0;
-
  }
-
  .heading {
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-bold);
-
    margin-bottom: 0.5rem;
-
  }
</style>

<header>
@@ -89,22 +72,7 @@
          Connect
        </Button>
        <div slot="popover" class="connect-popover">
-
          <div class="heading">Connect & Authenticate</div>
-
          <div class="label">
-
            Start the local node to browse projecs on your local machine, create
-
            issues, and participate in discussions.
-
          </div>
-
          <Command fullWidth command={`rad web ${window.origin}`} />
-

-
          <div class="divider" />
-
          <div class="heading">New to Radicle?</div>
-
          <div class="label">
-
            Run the following command and follow the instructions to install
-
            Radicle and get started.
-
          </div>
-
          <Command
-
            fullWidth
-
            command="sh <(curl -sSf https://radicle.xyz/install)" />
+
          <ConnectInstructions />
        </div>
      </Popover>
    {:else}
modified src/App/Header/Authenticate.svelte
@@ -5,11 +5,11 @@

  import Avatar from "@app/components/Avatar.svelte";
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Popover from "@app/components/Popover.svelte";
+
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
</script>

<style>
@@ -37,12 +37,6 @@
  .connect-popover {
    max-width: 20rem;
  }
-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
</style>

{#if $httpdStore.state === "authenticated"}
@@ -87,12 +81,7 @@
      Authenticate
    </Button>
    <div slot="popover" class="connect-popover">
-
      <div class="label">
-
        Authenticate with your local node to make changes.
-
      </div>
-
      <Command
-
        fullWidth
-
        command={`rad web ${window.origin} --connect ${httpd.api.hostname}:${httpd.api.port}`} />
+
      <ConnectInstructions />
    </div>
  </Popover>
{/if}
modified src/components/Badge.svelte
@@ -4,7 +4,7 @@
    | "foreground"
    | "background"
    | "outline"
-
    | "yellowOutline"
+
    | "yellow"
    | "neutral"
    | "negative"
    | "positive"
@@ -50,10 +50,9 @@
    color: var(--color-foreground-match-background);
    background-color: var(--color-fill-secondary);
  }
-
  .yellow-outline {
-
    border: 1px solid var(--color-foreground-yellow);
-
    color: var(--color-foreground-yellow);
-
    background-color: transparent;
+
  .yellow {
+
    color: var(--color-foreground-black);
+
    background-color: var(--color-fill-yellow);
  }
  .outline {
    border: 1px solid var(--color-border-hint);
@@ -112,7 +111,7 @@
  class:small={size === "small"}
  class:medium={size === "medium"}
  class:caution={variant === "caution"}
-
  class:yellow-outline={variant === "yellowOutline"}
+
  class:yellow={variant === "yellow"}
  class:outline={variant === "outline"}
  class:background={variant === "background"}
  class:foreground={variant === "foreground"}
added src/components/ConnectInstructions.svelte
@@ -0,0 +1,51 @@
+
<script>
+
  import { api, httpdStore } from "@app/lib/httpd";
+
  import Command from "./Command.svelte";
+
</script>
+

+
<style>
+
  .divider {
+
    height: 1px;
+
    width: 100%;
+
    background-color: var(--color-fill-separator);
+
    margin: 1rem 0;
+
  }
+

+
  .heading {
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-bold);
+
    margin-bottom: 0.5rem;
+
  }
+

+
  .label {
+
    display: block;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
+
    margin-bottom: 0.75rem;
+
    margin-top: 0.75rem;
+
  }
+
</style>
+

+
<div>
+
  {#if $httpdStore.state === "running"}
+
    <div class="label">Authenticate with your local node to make changes.</div>
+
    <Command
+
      fullWidth
+
      command={`rad web ${window.origin} --connect ${api.hostname}:${api.port}`} />
+
  {:else}
+
    <div class="heading">Connect & Authenticate</div>
+
    <div class="label">
+
      Start the local node to browse projects on your local machine, create
+
      issues, and participate in discussions.
+
    </div>
+
    <Command fullWidth command={`rad web ${window.origin}`} />
+

+
    <div class="divider" />
+
    <div class="heading">New to Radicle?</div>
+
    <div class="label">
+
      Run the following command and follow the instructions to install Radicle
+
      and get started.
+
    </div>
+
    <Command fullWidth command="sh <(curl -sSf https://radicle.xyz/install)" />
+
  {/if}
+
</div>
modified src/components/DropdownList/DropdownListItem.svelte
@@ -17,7 +17,6 @@
    font-size: var(--font-size-small);
    font-weight: var(--font-weight-regular);
    color: var(--color-foreground-contrast);
-
    height: 2rem;
  }
  .item.disabled {
    color: var(--color-foreground-disabled);
modified src/components/Icon.svelte
@@ -42,6 +42,7 @@
    | "no-issues"
    | "no-patches"
    | "patch"
+
    | "plus"
    | "review"
    | "sun";
  export let size: "24" | "48" = "24";
@@ -718,6 +719,11 @@
      16.5ZM18 16.5C17.1716 16.5 16.5 17.1716 16.5 18C16.5 18.8284 17.1716 19.5
      18 19.5C18.8284 19.5 19.5 18.8284 19.5 18C19.5 17.1716 18.8284 16.5 18
      16.5Z" />
+
  {:else if name === "plus"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M12.5 5.97656C12.5 5.70042 12.2761 5.47656 12 5.47656C11.7239 5.47656 11.5 5.70042 11.5 5.97656V12.4766H5C4.72386 12.4766 4.5 12.7004 4.5 12.9766C4.5 13.2527 4.72386 13.4766 5 13.4766H11.5V19.9766C11.5 20.2527 11.7239 20.4766 12 20.4766C12.2761 20.4766 12.5 20.2527 12.5 19.9766V13.4766L19 13.4766C19.2761 13.4766 19.5 13.2527 19.5 12.9766C19.5 12.7004 19.2761 12.4766 19 12.4766L12.5 12.4766V5.97656Z" />
  {:else if name === "review"}
    <path
      fill-rule="evenodd"
modified src/components/IconSmall.svelte
@@ -6,6 +6,7 @@
    | "activity"
    | "arrow-box-up-right"
    | "arrow-reply"
+
    | "badge"
    | "branch"
    | "broadcasting"
    | "brush"
@@ -85,6 +86,15 @@
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M2.87128 8.85806C2.89568 8.91703 2.93185 8.97228 2.97979 9.02022L6.31312 12.3536C6.50839 12.5488 6.82497 12.5488 7.02023 12.3536C7.21549 12.1583 7.21549 11.8417 7.02023 11.6464L4.54045 9.16667H12C12.6443 9.16667 13.1667 8.64433 13.1667 8V4C13.1667 3.72386 12.9428 3.5 12.6667 3.5C12.3905 3.5 12.1667 3.72386 12.1667 4V8C12.1667 8.09205 12.0921 8.16667 12 8.16667H4.54045L7.02023 5.68689C7.21549 5.49162 7.21549 5.17504 7.02023 4.97978C6.82497 4.78452 6.50839 4.78452 6.31312 4.97978L2.97979 8.31311C2.88292 8.40998 2.83411 8.53671 2.83335 8.66367C2.83335 8.66467 2.83334 8.66567 2.83334 8.66667C2.83334 8.66767 2.83335 8.66867 2.83335 8.66967C2.83374 8.73637 2.8472 8.79997 2.87128 8.85806Z" />
+
  {:else if name === "badge"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M10.0435 6.41199C10.0435 7.51656 9.14803 8.41199 8.04346 8.41199C6.93889 8.41199 6.04346 7.51656 6.04346 6.41199C6.04346 5.30742 6.93889 4.41199 8.04346 4.41199C9.14803 4.41199 10.0435 5.30742 10.0435 6.41199ZM9.04346 6.41199C9.04346 6.96427 8.59574 7.41199 8.04346 7.41199C7.49117 7.41199 7.04346 6.96427 7.04346 6.41199C7.04346 5.8597 7.49117 5.41199 8.04346 5.41199C8.59574 5.41199 9.04346 5.8597 9.04346 6.41199Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M8.70235 1.76514L9.06964 2.13244C9.25591 2.31871 9.50857 2.42335 9.77201 2.42335H11.0548C11.6034 2.42335 12.0481 2.86806 12.0481 3.41663V4.69945C12.0481 4.9629 12.1528 5.21555 12.3391 5.40182L12.8168 5.87958C13.2047 6.26748 13.2047 6.89641 12.8168 7.28428L12.3391 7.76207C12.1528 7.94834 12.0481 8.20099 12.0481 8.46441V9.43104C12.0481 9.78994 11.8577 10.1044 11.5725 10.2789L12.6676 12.9387C12.7421 13.1197 12.7034 13.3277 12.5686 13.4697C12.4338 13.6117 12.2281 13.6612 12.0435 13.5962L10.7832 13.1526L10.082 14.2898C9.98071 14.454 9.79344 14.5447 9.60178 14.5224C9.41011 14.5001 9.24873 14.3687 9.18792 14.1856L8.33941 11.6302C8.12056 11.7095 7.87942 11.7095 7.66057 11.6301L6.81206 14.1856C6.75125 14.3687 6.58987 14.5001 6.3982 14.5224C6.20654 14.5447 6.01927 14.454 5.91799 14.2898L5.21675 13.1526L3.95651 13.5962C3.77184 13.6612 3.5662 13.6117 3.4314 13.4697C3.2966 13.3277 3.25783 13.1197 3.33237 12.9387L4.44826 10.2286C4.20486 10.0475 4.04717 9.75769 4.04717 9.43104V8.55968C4.04717 8.29627 3.9425 8.04361 3.75623 7.85734L3.1832 7.28428C2.7953 6.89641 2.7953 6.26748 3.1832 5.87958L3.75623 5.30655C3.9425 5.12028 4.04717 4.86762 4.04717 4.60418V3.41663C4.04717 2.86806 4.49185 2.42335 5.04045 2.42335H6.228C6.49142 2.42335 6.74407 2.31871 6.93034 2.13244L7.29764 1.76514C7.68554 1.37724 8.31447 1.37724 8.70235 1.76514ZM8.00001 2.46748L8.3673 2.83478C8.73984 3.20732 9.24515 3.41663 9.77201 3.41663H11.0548V4.69945C11.0548 5.22634 11.2641 5.73162 11.6367 6.10416L12.1145 6.58194L11.6367 7.0597C11.2641 7.43227 11.0548 7.93755 11.0548 8.46441V9.43104H10.0882C9.56134 9.43104 9.05606 9.64032 8.68352 10.0129L8.00001 10.6964L7.31649 10.0129C6.94392 9.64032 6.43864 9.43104 5.91178 9.43104H5.04045V8.55968C5.04045 8.03282 4.83114 7.52754 4.4586 7.15497L3.88554 6.58194L4.4586 6.00889C4.83114 5.63635 5.04045 5.13107 5.04045 4.60418V3.41663H6.228C6.75486 3.41663 7.26014 3.20732 7.63271 2.83478L8.00001 2.46748ZM5.44185 10.4243L4.6733 12.2909L5.26446 12.0827C5.48483 12.0052 5.72948 12.0917 5.8521 12.2905L6.20478 12.8625L6.84207 10.9432L6.61412 10.7152C6.42785 10.529 6.17523 10.4243 5.91178 10.4243H5.44185ZM9.15791 10.9432L9.7952 12.8625L10.1479 12.2905C10.2705 12.0917 10.5152 12.0052 10.7355 12.0827L11.3267 12.2909L10.5581 10.4243H10.0882C9.82479 10.4243 9.57213 10.529 9.38586 10.7152L9.15791 10.9432Z" />
  {:else if name === "branch"}
    <path
      fill-rule="evenodd"
modified src/components/Popover.svelte
@@ -15,7 +15,7 @@
  export let popoverPositionRight: string | undefined = undefined;
  export let popoverPositionTop: string | undefined = undefined;

-
  let expanded = false;
+
  export let expanded = false;
  let thisComponent: HTMLDivElement;

  function clickOutside(ev: MouseEvent | TouchEvent) {
modified src/components/ProjectCard.svelte
@@ -1,162 +1,207 @@
<script lang="ts">
-
  import type { WeeklyActivity } from "@app/lib/commit";
+
  import type { BaseUrl } from "@httpd-client";

-
  import capitalize from "lodash/capitalize";
-
  import { formatCommit, twemoji } from "@app/lib/utils";
+
  import type { WeeklyActivity } from "@app/lib/commit";
+
  import { formatTimestamp, twemoji } from "@app/lib/utils";

  import ActivityDiagram from "@app/components/ActivityDiagram.svelte";
-
  import Badge from "@app/components/Badge.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Link from "@app/components/Link.svelte";

-
  export let activity: WeeklyActivity[];
  export let compact = false;
+

+
  export let activity: WeeklyActivity[];
  export let description: string;
-
  export let head: string;
-
  export let visibility: "public" | "private" = "public";
+
  export let baseUrl: BaseUrl;
+

+
  export let numberOfIssues: number;
+
  export let numberOfPatches: number;
+

+
  export let isDelegate: boolean;
+
  export let isSeeding: boolean;
+
  export let isPrivate: boolean;
+

+
  export let lastUpdatedTimestamp: number;
+

+
  $: lastUpdated = formatTimestamp(lastUpdatedTimestamp);
+

  export let id: string;
  export let name: string;
</script>

<style>
-
  .project {
-
    position: relative;
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    padding: 1rem;
-
    box-shadow: 0 0 0 1px var(--color-border-hint);
+
  .project-card {
+
    height: 10rem;
+
    border: 1px solid var(--color-border-default);
    border-radius: var(--border-radius-small);
-
    min-width: 36rem;
-
    cursor: pointer;
-
    background: var(--color-background-float);
-
  }
-
  .right {
+
    background-color: var(--color-background-float);
+
    padding: 0.75rem 1rem;
+
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
-
    align-items: flex-end;
+
    overflow: hidden;
  }
-
  .left {
-
    display: flex;
-
    flex-direction: column;
-
    width: 50%;
+

+
  .project-card.compact {
+
    height: 8rem;
  }
-
  .description {
-
    overflow-x: hidden;
-
    overflow-y: hidden;
-
    text-overflow: ellipsis;
+

+
  .project-card:hover {
+
    background-color: var(--color-fill-float-hover);
  }
-
  .compact {
-
    min-width: 16rem;
-
    height: 9rem;
+

+
  .activity {
+
    position: absolute;
+
    bottom: 1.5rem;
+
    right: 0;
+
    width: calc(100% - 3rem);
+
    max-width: 24rem;
  }
-
  .compact .left {
+

+
  .activity > .fadeout-overlay {
+
    position: absolute;
+
    bottom: 0;
+
    right: 0;
    width: 100%;
+
    height: 100%;
+
    background: linear-gradient(
+
      to right,
+
      var(--color-background-float) 20%,
+
      rgba(255, 255, 255, 0) 100%
+
    );
  }
-
  .compact .right {
-
    display: none;
+

+
  .project-card:hover .fadeout-overlay {
+
    background: linear-gradient(
+
      to right,
+
      var(--color-fill-float-hover) 20%,
+
      rgba(255, 255, 255, 0) 100%
+
    );
  }
-
  .compact .description {
-
    white-space: nowrap;
+

+
  .title {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.125rem;
+
    position: relative;
  }
-
  .activity {
-
    width: 100%;
-
    max-width: 14rem;
-
    margin-top: 0.5rem;
+

+
  .title * {
+
    line-clamp: 1;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
  }
-
  .compact .activity {
-
    position: absolute;
-
    bottom: 0;
+

+
  .title p {
+
    color: var(--color-foreground-dim);
  }
-
  .project:hover {
-
    box-shadow: 0 0 0 2px var(--color-border-focus);
+

+
  .headline-and-badges {
+
    display: flex;
+
    justify-content: space-between;
+
    gap: 0.5rem;
  }
-
  .description {
-
    font-size: var(--font-size-small);
+

+
  .badges {
+
    display: flex;
+
    gap: 0.25rem;
+
    flex-shrink: 0;
  }
-
  .title {
+

+
  .badge {
    display: flex;
-
    flex-direction: row;
-
    gap: 1rem;
    align-items: center;
-
    font-size: var(--font-size-medium);
-
    font-weight: var(--font-weight-medium);
-
  }
-
  .name {
-
    font-weight: var(--font-weight-semibold);
+
    justify-content: center;
+
    width: 1.5rem;
+
    height: 1.5rem;
+
    border-radius: 50%;
+
    overflow: hidden;
+
    position: relative;
  }
-
  .rid {
-
    visibility: hidden;
-
    color: var(--color-fill-secondary);
-
    font-family: var(--font-family-monospace);
-
    font-size: var(--font-size-tiny);
+

+
  .badge-background {
+
    position: absolute;
+
    top: 0;
+
    left: 0;
+
    width: 100%;
+
    height: 100%;
+
    border-radius: 50%;
+
    opacity: 0.25;
  }
-
  .project:hover .rid {
-
    visibility: visible;
+

+
  h4,
+
  p {
+
    margin: 0;
  }
-
  .text {
+

+
  .stats-row {
+
    position: relative;
    display: flex;
    gap: 0.25rem;
-
    flex-direction: column;
+
    height: 1.5rem;
+
    align-items: center;
+
    white-space: nowrap;
  }
</style>

-
<div class="project" class:compact>
-
  <div class="left">
-
    <div class="text">
-
      <div class="title">
-
        <span class="name txt-overflow" title={name}>
-
          {name}
-
        </span>
-
        {#if visibility === "private"}
-
          {#if compact}
-
            <div title="Private" style:color="var(--color-foreground-yellow)">
+
<Link
+
  route={{
+
    resource: "project.source",
+
    project: id,
+
    node: baseUrl,
+
  }}>
+
  <div class="project-card" class:compact>
+
    <div class="activity">
+
      <div class="fadeout-overlay" />
+
      <ActivityDiagram
+
        {id}
+
        viewBoxHeight={200}
+
        styleColor="var(--color-foreground-primary"
+
        {activity} />
+
    </div>
+
    <div class="title">
+
      <div class="headline-and-badges">
+
        <h4 use:twemoji>{name}</h4>
+
        <div class="badges">
+
          {#if isPrivate}
+
            <div title="Private" class="badge">
+
              <div
+
                class="badge-background"
+
                style:background-color="var(--color-fill-yellow)" />
              <IconSmall name="lock" />
            </div>
-
          {:else}
-
            <Badge variant="yellowOutline" size="tiny">
-
              {capitalize(visibility)}
-
            </Badge>
          {/if}
-
        {/if}
-
      </div>
-
      <div class="description" use:twemoji>{description}</div>
-
      <div class="global-commit">
-
        {#if compact}
-
          {formatCommit(head)}
-
        {:else}
-
          {head}
-
        {/if}
+
          {#if isDelegate}
+
            <div title="Delegate" class="badge">
+
              <div
+
                class="badge-background"
+
                style:background-color="var(--color-foreground-primary)" />
+
              <IconSmall name="badge" />
+
            </div>
+
          {/if}
+
          {#if isSeeding}
+
            <div title="Seeding" class="badge">
+
              <div
+
                class="badge-background"
+
                style:background-color="var(--color-fill-secondary)" />
+
              <IconSmall name="network" />
+
            </div>
+
          {/if}
+
        </div>
      </div>
+
      <p class="txt-small" use:twemoji>{description}</p>
    </div>
-

-
    {#if compact}
-
      <div class="activity">
-
        <ActivityDiagram
-
          {id}
-
          {activity}
-
          viewBoxHeight={70}
-
          styleColor={visibility === "private"
-
            ? "var(--color-foreground-yellow)"
-
            : "var(--color-foreground-primary)"} />
-
      </div>
-
    {/if}
-
  </div>
-

-
  {#if !compact}
-
    <div class="right">
-
      <div class="id">
-
        <span class="rid">{id}</span>
-
      </div>
-
      <div class="activity">
-
        <ActivityDiagram
-
          {id}
-
          {activity}
-
          viewBoxHeight={100}
-
          styleColor={visibility === "private"
-
            ? "var(--color-foreground-yellow)"
-
            : "var(--color-foreground-primary)"} />
-
      </div>
+
    <div class="stats-row txt-tiny" style:color="var(--color-foreground-dim)">
+
      <IconSmall name="issue" />
+
      {numberOfIssues} ·
+
      <IconSmall name="patch" />
+
      <span
+
        style:overflow="hidden"
+
        style:text-overflow="ellipsis">
+
        {numberOfPatches} · Updated {lastUpdated}
+
      </span>
    </div>
-
  {/if}
-
</div>
+
  </div>
+
</Link>
added src/components/TransitionedHeight.svelte
@@ -0,0 +1,116 @@
+
<script lang="ts">
+
  import type { Tweened } from "svelte/motion";
+

+
  import { onMount } from "svelte";
+
  import { cubicInOut } from "svelte/easing";
+
  import { tweened } from "svelte/motion";
+

+
  /** Force a height of 0, and optionally apply `negativeMarginWhileCollapsed`. */
+
  export let collapsed = false;
+

+
  /**
+
   * If true, all content height changes are transitioned. If false, only collapsing and expanding
+
   * the content is transitioned.
+
   */
+
  export let transitionHeightChanges = false;
+

+
  /**
+
   * Force a negative margin while collapsed. This is useful when you use `transitionedHeight`
+
   * in the context of some layout where there's further content below.
+
   */
+
  export let negativeMarginWhileCollapsed: string | undefined = undefined;
+

+
  let contentContainerElem: HTMLDivElement;
+
  let fitContent = !collapsed;
+

+
  let containerHeight: Tweened<number> | undefined;
+
  onMount(() => {
+
    if (collapsed) {
+
      containerHeight = tweened(0);
+
    } else {
+
      containerHeight = tweened(
+
        contentContainerElem.getBoundingClientRect().height,
+
      );
+
    }
+
  });
+

+
  let animating = false;
+
  let zeroHeight = collapsed;
+
  let previouslyCollapsed = collapsed;
+

+
  async function updateHeight() {
+
    if (!containerHeight) return;
+

+
    const newHeight = collapsed
+
      ? 0
+
      : contentContainerElem.getBoundingClientRect().height;
+

+
    const collapsedChanged = previouslyCollapsed !== collapsed;
+

+
    const shouldTransition = transitionHeightChanges || collapsedChanged;
+

+
    if (collapsed && !collapsedChanged) return;
+

+
    // Setting fitContent to false so that we can smoothly animate the container height.
+
    if (shouldTransition) {
+
      fitContent = false;
+
      animating = true;
+
    }
+

+
    void containerHeight.set(
+
      newHeight,
+
      shouldTransition ? { duration: 300, easing: cubicInOut } : undefined,
+
    );
+

+
    if (shouldTransition && !collapsed) {
+
      setTimeout(() => {
+
        fitContent = true;
+
        animating = false;
+
      }, 300);
+
    }
+

+
    zeroHeight = newHeight === 0;
+

+
    previouslyCollapsed = collapsed;
+
  }
+
  $: {
+
    collapsed;
+
    void updateHeight();
+
  }
+

+
  let sizeObserver: ResizeObserver | undefined;
+
  onMount(() => {
+
    sizeObserver = new ResizeObserver(updateHeight);
+
    sizeObserver.observe(contentContainerElem);
+

+
    return () => sizeObserver?.disconnect();
+
  });
+

+
  $: heightStyleString = fitContent ? "fit-content" : `${$containerHeight}px`;
+
</script>
+

+
<style>
+
  .transitioned-height {
+
    width: 100%;
+
    transition: margin-bottom 0.3s;
+
    position: relative;
+
  }
+

+
  .animating,
+
  .zero-height {
+
    overflow: hidden;
+
  }
+
</style>
+

+
<div
+
  class="transitioned-height"
+
  class:animating
+
  class:zero-height={zeroHeight}
+
  style:margin-bottom={negativeMarginWhileCollapsed && zeroHeight === true
+
    ? negativeMarginWhileCollapsed
+
    : undefined}
+
  style:height={heightStyleString}>
+
  <div class="inner" bind:this={contentContainerElem}>
+
    <slot />
+
  </div>
+
</div>
modified src/config.json
@@ -17,71 +17,9 @@
    ]
  },
  "reactions": ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"],
-
  "projects": {
-
    "pinned": [
-
      {
-
        "name": "radicle-interface",
-
        "id": "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "heartwood",
-
        "id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "rips",
-
        "id": "rad:z3trNYnLWS11cJWC6BbxDs5niGo82",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "radicle-team",
-
        "id": "rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "awesome-radicle",
-
        "id": "rad:z3yQUb9HDAC7TQrUDGkQsXDsYFj9G",
-
        "baseUrl": {
-
          "hostname": "seed.rhizoma.dev",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "radicle-tui",
-
        "id": "rad:z39mP9rQAaGmERfUMPULfPUi473tY",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      },
-
      {
-
        "name": "radicle-homepage",
-
        "id": "rad:z2ATVRDqFu2Yq2MG5ZZCWecwSDYqj",
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      }
-
    ]
+
  "fallbackPreferredSeed": {
+
    "hostname": "seed.radicle.garden",
+
    "port": 443,
+
    "scheme": "https"
  }
}
modified src/lib/commit.ts
@@ -116,3 +116,10 @@ export async function loadProjectActivity(id: string, baseUrl: BaseUrl) {

  return groupCommitsByWeek(commits.activity);
}
+

+
export async function fetchLastCommit(id: string, baseUrl: BaseUrl) {
+
  const api = new HttpdClient(baseUrl);
+
  const res = await api.project.getAllCommits(id, { perPage: 1 });
+

+
  return res.commits[0];
+
}
modified src/lib/config.ts
@@ -12,13 +12,7 @@ export interface Config {
    defaultHttpdScheme: string;
    pinned: { baseUrl: BaseUrl }[];
  };
-
  projects: {
-
    pinned: {
-
      name: string;
-
      id: string;
-
      baseUrl: BaseUrl;
-
    }[];
-
  };
+
  fallbackPreferredSeed: BaseUrl;
}

function getConfig(): Config {
@@ -33,7 +27,11 @@ function getConfig(): Config {
        defaultNodePort: 8776,
        pinned: [],
      },
-
      projects: { pinned: [] },
+
      fallbackPreferredSeed: {
+
        hostname: "seed.radicle.garden",
+
        port: 443,
+
        scheme: "https",
+
      },
    };
  } else if (window.PLAYWRIGHT) {
    return window.APP_CONFIG;
modified src/lib/projects.ts
@@ -3,12 +3,22 @@ import type { BaseUrl, Project } from "@httpd-client";
import { HttpdClient } from "@httpd-client";
import { isFulfilled } from "@app/lib/utils";
import { cached } from "./cache";
+
import {
+
  fetchLastCommit,
+
  loadProjectActivity,
+
  type WeeklyActivity,
+
} from "./commit";

export interface ProjectBaseUrl {
  project: Project;
  baseUrl: BaseUrl;
}

+
export interface ProjectWithListingData extends ProjectBaseUrl {
+
  activity: WeeklyActivity[];
+
  lastCommit: Awaited<ReturnType<typeof fetchLastCommit>>;
+
}
+

export async function getProjectsFromNodes(
  params: { id: string; baseUrl: BaseUrl }[],
): Promise<ProjectBaseUrl[]> {
@@ -25,6 +35,32 @@ export async function getProjectsFromNodes(
  return results.filter(isFulfilled).map(r => r.value);
}

+
export async function getProjectListingData(id: string, baseUrl: BaseUrl) {
+
  const activity = await loadProjectActivity(id, baseUrl);
+
  const lastCommit = await fetchLastCommit(id, baseUrl);
+

+
  return { activity, lastCommit };
+
}
+

+
export async function getProjectsListingData(projects: ProjectBaseUrl[]) {
+
  const result = await Promise.all(
+
    projects.map(async ({ project, baseUrl }) => {
+
      const { activity, lastCommit } = await getProjectListingData(
+
        project.id,
+
        baseUrl,
+
      );
+
      return { project, activity, lastCommit, baseUrl };
+
    }),
+
  );
+

+
  return result.sort((a, b) => {
+
    const aLastCommit = a.lastCommit?.commit.committer.time ?? 0;
+
    const bLastCommit = b.lastCommit?.commit.committer.time ?? 0;
+

+
    return bLastCommit - aLastCommit;
+
  });
+
}
+

export const cacheQueryProject = cached(
  queryProject,
  (baseUrl: BaseUrl, projectId: string) =>
added src/lib/seeds.ts
@@ -0,0 +1,122 @@
+
import storedWritable from "@efstajas/svelte-stored-writable";
+
import type { BaseUrl } from "@httpd-client";
+
import { z } from "zod";
+
import { api, httpdStore, type HttpdState } from "./httpd";
+
import { writable, derived, get } from "svelte/store";
+
import { config } from "./config";
+

+
const preferredSeedSchema = z
+
  .object({
+
    hostname: z.string(),
+
    port: z.number(),
+
    scheme: z.string(),
+
  })
+
  .optional();
+

+
const configuredPreferredSeeds = writable<BaseUrl[] | undefined>(undefined);
+
const storedPreferredSeed = storedWritable<BaseUrl | undefined>(
+
  "preferredSeed",
+
  preferredSeedSchema,
+
  undefined,
+
);
+

+
async function loadConfiguredPreferredSeeds() {
+
  if (get(httpdStore).state === "stopped") {
+
    configuredPreferredSeeds.set([]);
+
    return;
+
  }
+

+
  const profile = await api.profile.getProfile();
+

+
  let newValue = profile.config.preferredSeeds.map(seed => {
+
    const preferredSeedValue = seed?.split("@")[1];
+
    const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
+

+
    return {
+
      hostname: preferredSeedOrigin,
+
      port: 443,
+
      scheme: "https",
+
    };
+
  });
+

+
  if (newValue.length === 0) {
+
    newValue = [config.fallbackPreferredSeed];
+
  }
+

+
  configuredPreferredSeeds.set(newValue);
+
}
+

+
export function initialize() {
+
  let previousHttpdState: HttpdState["state"] | undefined;
+

+
  httpdStore.subscribe(async v => {
+
    if (previousHttpdState === v.state) return;
+

+
    await loadConfiguredPreferredSeeds();
+

+
    previousHttpdState = v.state;
+
  });
+
}
+

+
export function selectPreferredSeed(seed: BaseUrl) {
+
  storedPreferredSeed.set(seed);
+
}
+

+
export const prefferedSeeds = derived(
+
  [configuredPreferredSeeds, storedPreferredSeed],
+
  ([configuredPreferredSeeds, storedPreferredSeed]) => {
+
    // Not loaded yet
+
    if (!configuredPreferredSeeds) return undefined;
+

+
    // No configured preferred seeds
+
    if (configuredPreferredSeeds.length === 0)
+
      return {
+
        selected: config.fallbackPreferredSeed,
+
        seeds: [config.fallbackPreferredSeed],
+
      };
+

+
    // No stored preferred seed
+
    if (!storedPreferredSeed)
+
      return {
+
        selected: configuredPreferredSeeds[0],
+
        seeds: configuredPreferredSeeds,
+
      };
+

+
    // Stored preferred seed not configured
+
    if (
+
      !configuredPreferredSeeds.some(
+
        seed => seed.hostname === storedPreferredSeed.hostname,
+
      )
+
    )
+
      return {
+
        selected: configuredPreferredSeeds[0],
+
        seeds: configuredPreferredSeeds,
+
      };
+

+
    return {
+
      selected: storedPreferredSeed,
+
      seeds: configuredPreferredSeeds,
+
    };
+
  },
+
);
+

+
export async function waitForLoad(): Promise<{
+
  selected: BaseUrl;
+
  seeds: BaseUrl[];
+
}> {
+
  if (!get(configuredPreferredSeeds)) {
+
    await new Promise<void>(resolve => {
+
      const unsubscribe = prefferedSeeds.subscribe(v => {
+
        if (v) {
+
          unsubscribe();
+
          resolve();
+
        }
+
      });
+
    });
+
  }
+

+
  const seeds = get(prefferedSeeds);
+
  if (!seeds) throw new Error("Preferred seed undefined after loading");
+

+
  return seeds;
+
}
modified src/views/home/Index.svelte
@@ -1,101 +1,213 @@
<script lang="ts">
-
  import type { ProjectBaseUrlActivity } from "./router";
+
  import type { ProjectWithListingData } from "@app/lib/projects";
+
  import type { BaseUrl } from "@httpd-client";

-
  import { isLocal, twemoji } from "@app/lib/utils";
+
  import { z } from "zod";
+
  import storedWritable from "@efstajas/svelte-stored-writable";
+

+
  import { httpdStore } from "@app/lib/httpd";
+
  import { isDelegate } from "@app/lib/roles";

  import AppLayout from "@app/App/AppLayout.svelte";
-
  import Link from "@app/components/Link.svelte";
+
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";
+
  import Button from "@app/components/Button.svelte";
+

+
  import FilterButton from "./components/FilterButton.svelte";
+
  import NewProjectButton from "./components/NewProjectButton.svelte";
+
  import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
+
  import HomepageSection from "./components/HomepageSection.svelte";
+

+
  export let localProjects: ProjectWithListingData[] | "error";
+
  export let preferredSeedProjects: ProjectWithListingData[] | "error";
+
  export let preferredSeed: BaseUrl;
+
  export let nodeId: string | undefined;
+

+
  const localProjectsFilterSchema = z.union([
+
    z.literal("all"),
+
    z.literal("delegating"),
+
  ]);
+

+
  const localProjectsFilter = storedWritable(
+
    "localProjectsFilter",
+
    localProjectsFilterSchema,
+
    "all",
+
  );
+

+
  $: filteredLocalProjects =
+
    $localProjectsFilter === "all" || localProjects === "error"
+
      ? localProjects
+
      : localProjects.filter(p => isDelegate(nodeId, p.project.delegates));
+

+
  function isSeeding(projectId: string) {
+
    if (localProjects === "error") return false;
+
    return localProjects.some(p => p.project.id === projectId);
+
  }
+

+
  let prevHttpdState = $httpdStore.state;

-
  export let projects: ProjectBaseUrlActivity[];
+
  function handleHttpdStateChange(newState: (typeof $httpdStore)["state"]) {
+
    if (prevHttpdState === newState) return;
+

+
    if (newState === "stopped" || newState === "authenticated") {
+
      window.location.reload();
+
    }
+

+
    prevHttpdState = newState;
+
  }
+

+
  $: handleHttpdStateChange($httpdStore.state);
</script>

<style>
  .wrapper {
-
    padding: 3rem 16vw;
+
    padding: 3rem;
+
    max-width: 72rem;
+
    margin: 0 auto;
    width: 100%;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 3rem;
  }
-
  .blurb {
-
    color: var(--color-foreground-contrast);
-
    padding: 0rem;
-
    font-size: var(--font-size-medium);
-
    text-align: left;
-
    margin-bottom: 1.5rem;
-
  }
-
  .projects {
+
  .empty-state {
+
    text-align: center;
    display: flex;
-
    flex-direction: row;
-
    flex-wrap: wrap;
-
    gap: 2.5rem;
-
    width: 100%;
+
    flex-direction: column;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .empty-state .heading {
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-bold);
  }
-
  .project {
-
    width: 16rem;
+
  .empty-state .label {
+
    display: block;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-regular);
  }
-
  .heading {
-
    color: var(--color-foreground-emphasized);
-
    padding: 1rem 0rem;
-
    font-size: var(--font-size-medium);
-
    margin-bottom: 1rem;
+
  .empty-state .action {
+
    margin-top: 0.5rem;
+
  }
+
  .project-grid {
+
    display: grid;
+
    grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
+
    gap: 1rem;
  }

  @media (max-width: 720px) {
-
    .project {
-
      width: 100%;
-
    }
-
    .projects {
-
      margin-bottom: 1.5rem;
-
      gap: 1.5rem;
-
    }
    .wrapper {
      width: 100%;
-
      padding: 1rem 1.5rem 0 1.5rem;
+
      padding: 1rem 1.5rem 1.5rem 1.5rem;
+
    }
+

+
    .seed-dropdown {
+
      display: none;
    }
  }
</style>

<AppLayout>
  <div class="wrapper">
-
    <div class="blurb">
-
      <p use:twemoji>
-
        Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
-
        <br class="global-hide-on-mobile" />
-
        over a peer-to-peer network 🌐 built on Git.
-
      </p>
-
    </div>
-

-
    {#if projects.length > 0}
-
      <div class="heading">
-
        {#if isLocal(projects[0].baseUrl.hostname)}
-
          <!-- prettier-ignore -->
-
          <span>Explore projects on your <span class="txt-bold">local node</span>.</span>
-
        {:else}
-
          <!-- prettier-ignore -->
-
          <span>Explore projects on the <span class="txt-bold">Radicle network</span>.</span>
-
        {/if}
-
      </div>
-

-
      <div class="projects">
-
        {#each projects as { project, baseUrl, activity }}
-
          <div class="project">
-
            <Link
-
              route={{
-
                resource: "project.source",
-
                project: project.id,
-
                node: baseUrl,
-
              }}>
+
    <div class="global-hide-on-mobile">
+
      <HomepageSection
+
        empty={localProjects === "error" ||
+
          !nodeId ||
+
          !filteredLocalProjects.length}
+
        title="Local projects"
+
        subtitle="Projects you’re seeding with your local node">
+
        <svelte:fragment slot="actions">
+
          <FilterButton disabled={!nodeId} bind:value={$localProjectsFilter} />
+
          <NewProjectButton disabled={!nodeId} />
+
        </svelte:fragment>
+
        <svelte:fragment slot="empty">
+
          <div class="empty-state">
+
            {#if !nodeId}
+
              <div style:text-align="left">
+
                <ConnectInstructions />
+
              </div>
+
            {:else if localProjects === "error"}
+
              <div class="heading">Error loading projects</div>
+
              <div class="label">
+
                There was an error loading projects from your local node.
+
              </div>
+
              <div class="action"><Button>Learn more</Button></div>
+
            {:else if !localProjects.length}
+
              <div class="heading">No local projects</div>
+
              <div class="label">
+
                Seed or check out a project to work with it on your local node.
+
              </div>
+
            {:else}
+
              <div class="heading">Nothing to see here</div>
+
              <div class="label">
+
                No local projects matched your filter settings.
+
              </div>
+
            {/if}
+
          </div>
+
        </svelte:fragment>
+
        <div class="project-grid">
+
          {#if filteredLocalProjects !== "error"}
+
            {#each filteredLocalProjects as { project, baseUrl, activity, lastCommit }}
              <ProjectCard
-
                compact
-
                description={project.description}
-
                head={project.head}
-
                visibility={project.visibility?.type}
                id={project.id}
                name={project.name}
-
                {activity} />
-
            </Link>
-
          </div>
-
        {/each}
+
                description={project.description}
+
                numberOfIssues={project.issues.open}
+
                numberOfPatches={project.patches.open}
+
                isPrivate={project.visibility?.type === "private"}
+
                isSeeding={true}
+
                isDelegate={isDelegate(nodeId, project.delegates) ?? false}
+
                lastUpdatedTimestamp={lastCommit.commit.committer.time}
+
                {activity}
+
                {baseUrl} />
+
            {/each}
+
          {/if}
+
        </div>
+
      </HomepageSection>
+
    </div>
+

+
    <HomepageSection
+
      empty={preferredSeedProjects === "error" ||
+
        preferredSeedProjects.length === 0}
+
      title="Explore"
+
      subtitle="Pinned projects on your selected seed node">
+
      <svelte:fragment slot="actions">
+
        <div class="seed-dropdown">
+
          <PreferredSeedDropdown disabled={!nodeId} {preferredSeed} />
+
        </div>
+
      </svelte:fragment>
+
      <svelte:fragment slot="empty">
+
        <div class="empty-state">
+
          {#if preferredSeedProjects === "error"}
+
            <div class="heading">Something went wrong</div>
+
            <div class="label">
+
              There was an error loading projects from your preferred seed node.
+
            </div>
+
          {:else}
+
            <div class="heading">Nothing to see here</div>
+
            <div class="label">
+
              Your preferred seed node doesn't have any pinned projects.
+
            </div>
+
          {/if}
+
        </div>
+
      </svelte:fragment>
+
      <div class="project-grid">
+
        {#if preferredSeedProjects !== "error"}
+
          {#each preferredSeedProjects as { project, baseUrl, activity, lastCommit }}
+
            <ProjectCard
+
              id={project.id}
+
              name={project.name}
+
              description={project.description}
+
              numberOfIssues={project.issues.open}
+
              numberOfPatches={project.patches.open}
+
              isPrivate={project.visibility?.type === "private"}
+
              isSeeding={isSeeding(project.id)}
+
              isDelegate={isDelegate(nodeId, project.delegates) ?? false}
+
              lastUpdatedTimestamp={lastCommit.commit.committer.time}
+
              {activity}
+
              {baseUrl} />
+
          {/each}
+
        {/if}
      </div>
-
    {/if}
+
    </HomepageSection>
  </div>
</AppLayout>
added src/views/home/components/FilterButton.svelte
@@ -0,0 +1,104 @@
+
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+

+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+

+
  const stateOptions: {
+
    value: typeof value;
+
    title: string;
+
    description: string;
+
    iconName: ComponentProps<IconSmall>["name"];
+
  }[] = [
+
    {
+
      value: "all",
+
      title: "All projects",
+
      description: "Show all projects you’re seeding with your local node.",
+
      iconName: "globe",
+
    },
+
    {
+
      value: "delegating",
+
      title: "Delegate only",
+
      description: "Show only projects that you’re seeding and a delegate of.",
+
      iconName: "badge",
+
    },
+
  ];
+

+
  export let value: "all" | "delegating" = "all";
+
  export let disabled = false;
+

+
  let expanded = false;
+
</script>
+

+
<style>
+
  .popover-content {
+
    width: 20rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+

+
  .label {
+
    display: flex;
+
    white-space: initial;
+
  }
+

+
  .label .text > * {
+
    display: flex;
+
    gap: 0.25rem;
+
    align-items: center;
+
  }
+

+
  .dim {
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<Popover
+
  bind:expanded
+
  popoverPositionTop="2.5rem"
+
  popoverPositionRight="0"
+
  popoverPadding="0.25rem"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <Button
+
    {disabled}
+
    variant="outline"
+
    let:toggle
+
    slot="toggle"
+
    on:click={toggle}>
+
    {#if value === "all"}
+
      <IconSmall name="globe" />
+
      All projects
+
    {:else}
+
      <IconSmall name="badge" />
+
      Only delegating
+
    {/if}
+
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
  </Button>
+

+
  <div class="popover-content" slot="popover">
+
    <DropdownList items={stateOptions}>
+
      <DropdownListItem
+
        on:click={() => {
+
          value = item.value;
+
          closeFocused();
+
        }}
+
        slot="item"
+
        let:item
+
        selected={item.value === value}>
+
        <div class="label">
+
          <div class="text txt-small">
+
            <span class="txt-bold">
+
              <IconSmall name={item.iconName} />{item.title}
+
            </span>
+
            <span class="dim">{item.description}</span>
+
          </div>
+
        </div>
+
      </DropdownListItem>
+
    </DropdownList>
+
  </div>
+
</Popover>
added src/views/home/components/HomepageSection.svelte
@@ -0,0 +1,79 @@
+
<script lang="ts">
+
  import TransitionedHeight from "@app/components/TransitionedHeight.svelte";
+

+
  export let title: string;
+
  export let subtitle: string;
+

+
  export let empty: boolean = false;
+
</script>
+

+
<style>
+
  .section-header {
+
    display: flex;
+
    gap: 1rem;
+
    flex-wrap: wrap;
+
    justify-content: space-between;
+
    align-items: center;
+
    margin-bottom: 1.5rem;
+
  }
+

+
  .title > * {
+
    margin: 0;
+
  }
+

+
  .title > p {
+
    margin-top: 0.25rem;
+
    color: var(--color-foreground-dim);
+
  }
+

+
  .actions {
+
    display: flex;
+
    gap: 0.5rem;
+
    margin-left: auto;
+
  }
+

+
  .empty-container {
+
    background-color: var(--color-background-float);
+
    border-radius: var(--border-radius-small);
+
    border: 1px solid var(--color-border-hint);
+
    display: flex;
+
    justify-content: center;
+
    padding: 3rem 1rem;
+
    align-items: center;
+
    opacity: 0.75;
+
  }
+

+
  .empty-container > .inner {
+
    max-width: 32rem;
+
    min-height: 12rem;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
  }
+
</style>
+

+
<section>
+
  <div class="section-header">
+
    <div class="title">
+
      <h2>{title}</h2>
+
      <p>{subtitle}</p>
+
    </div>
+
    <div class="actions">
+
      <slot name="actions" />
+
    </div>
+
  </div>
+

+
  <TransitionedHeight transitionHeightChanges>
+
    {#if empty}
+
      <div class="empty-container">
+
        <div class="inner">
+
          <slot name="empty" />
+
        </div>
+
      </div>
+
    {:else}
+
      <div>
+
        <slot />
+
      </div>
+
    {/if}
+
  </TransitionedHeight>
+
</section>
added src/views/home/components/NewProjectButton.svelte
@@ -0,0 +1,38 @@
+
<script lang="ts">
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+

+
  export let disabled = false;
+
</script>
+

+
<style>
+
  .popover {
+
    min-width: 16rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<Popover
+
  popoverPositionTop="2.5rem"
+
  popoverPositionRight="0"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <Button
+
    {disabled}
+
    variant="secondary"
+
    let:toggle
+
    slot="toggle"
+
    on:click={toggle}>
+
    <IconSmall name="plus" />
+
    New project
+
  </Button>
+

+
  <div slot="popover" class="popover txt-small">
+
    Run the following command within an already-existing Git repository to
+
    create a new Radicle project.
+
    <Command fullWidth command="rad init" />
+
  </div>
+
</Popover>
added src/views/home/components/PreferredSeedDropdown.svelte
@@ -0,0 +1,97 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@httpd-client";
+

+
  import {
+
    prefferedSeeds as preferredSeedsStore,
+
    selectPreferredSeed,
+
  } from "@app/lib/seeds";
+

+
  import Popover from "@app/components/Popover.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import Command from "@app/components/Command.svelte";
+

+
  export let preferredSeed: BaseUrl;
+
  export let disabled = false;
+

+
  $: stateOptions = $preferredSeedsStore?.seeds;
+

+
  let expanded = false;
+
</script>
+

+
<style>
+
  .popover {
+
    width: 16rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+

+
  .label {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+

+
  .divider {
+
    height: 1px;
+
    width: 100%;
+
    margin: 0.5rem 0.25rem;
+
    background-color: var(--color-border-default);
+
  }
+

+
  .add-seed-node-instructions {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    padding: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<Popover
+
  bind:expanded
+
  popoverPositionTop="2.5rem"
+
  popoverPositionRight="0"
+
  popoverPadding="0.25rem"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <Button
+
    variant="outline"
+
    slot="toggle"
+
    let:toggle
+
    on:click={toggle}
+
    title="Change peer"
+
    {disabled}>
+
    {preferredSeed.hostname}
+
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
  </Button>
+

+
  <svelte:fragment slot="popover">
+
    <div class="popover">
+
      {#if stateOptions}
+
        <DropdownList items={stateOptions}>
+
          <DropdownListItem
+
            let:item
+
            on:click={() => {
+
              selectPreferredSeed(item);
+
              window.location.reload();
+
            }}
+
            slot="item"
+
            selected={item.hostname === preferredSeed.hostname}>
+
            <div class="label">
+
              {item.hostname}
+
            </div>
+
          </DropdownListItem>
+
        </DropdownList>
+
      {/if}
+
    </div>
+
    <div class="divider" />
+
    <div class="add-seed-node-instructions txt-small">
+
      <div class="" style:font-weight="bold">Add a different seed node</div>
+
      <div class="">Update your preferred seeds in your radicle config.</div>
+
      <Command fullWidth command="rad self --config" />
+
    </div>
+
  </svelte:fragment>
+
</Popover>
modified src/views/home/router.ts
@@ -1,17 +1,13 @@
-
import type { LoadErrorRoute } from "@app/lib/router/definitions";
-
import type { ProjectBaseUrl } from "@app/lib/projects";
-
import type { WeeklyActivity } from "@app/lib/commit";
+
import type { BaseUrl } from "@httpd-client";
+
import type { ProjectWithListingData } from "@app/lib/projects";

import { get } from "svelte/store";

+
import type { LoadErrorRoute } from "@app/lib/router/definitions";
+
import { getProjectsListingData } from "@app/lib/projects";
+
import * as seeds from "@app/lib/seeds";
import { api, httpdStore } from "@app/lib/httpd";
-
import { config } from "@app/lib/config";
-
import { getProjectsFromNodes } from "@app/lib/projects";
-
import { loadProjectActivity } from "@app/lib/commit";
-

-
export interface ProjectBaseUrlActivity extends ProjectBaseUrl {
-
  activity: WeeklyActivity[];
-
}
+
import { HttpdClient } from "@httpd-client";

export interface HomeRoute {
  resource: "home";
@@ -19,35 +15,63 @@ export interface HomeRoute {

export interface HomeLoadedRoute {
  resource: "home";
-
  params: { projects: ProjectBaseUrlActivity[] };
+
  params: {
+
    nodeId: string | undefined;
+
    localProjects: ProjectWithListingData[] | "error";
+
    preferredSeedProjects: ProjectWithListingData[] | "error";
+
    preferredSeed: BaseUrl;
+
  };
+
}
+

+
const fetchProjects = async (baseUrl: BaseUrl, show: "all" | "pinned") => {
+
  const api = new HttpdClient(baseUrl);
+

+
  return (await api.project.getAll({ perPage: 30, show })).map(project => ({
+
    project,
+
    baseUrl,
+
  }));
+
};
+

+
function handleProjectLoadError(error: unknown): "error" {
+
  console.error(error);
+
  return "error";
}

export async function loadHomeRoute(): Promise<
  HomeLoadedRoute | LoadErrorRoute
> {
-
  let projects: ProjectBaseUrl[] = [];
-
  if (get(httpdStore).state !== "stopped") {
-
    projects = (await api.project.getAll({ perPage: 30, show: "all" })).map(
-
      project => ({
-
        project,
-
        baseUrl: api.baseUrl,
-
      }),
-
    );
-
  } else {
-
    projects = await getProjectsFromNodes(config.projects.pinned);
-
  }
-
  const results = await Promise.all(
-
    projects.map(async projectNode => {
-
      const activity = await loadProjectActivity(
-
        projectNode.project.id,
-
        projectNode.baseUrl,
-
      );
-
      return {
-
        ...projectNode,
-
        activity,
-
      };
-
    }),
-
  );
-

-
  return { resource: "home", params: { projects: results } };
+
  seeds.initialize();
+
  const preferredSeeds = await seeds.waitForLoad();
+

+
  const connectedToLocalNode = get(httpdStore).state !== "stopped";
+

+
  const [localProjects, seedProjects] = await Promise.all([
+
    connectedToLocalNode
+
      ? fetchProjects(api.baseUrl, "all").catch(handleProjectLoadError)
+
      : undefined,
+
    fetchProjects(preferredSeeds.selected, "pinned").catch(
+
      handleProjectLoadError,
+
    ),
+
  ]);
+

+
  const projectsWithListingData = await Promise.all([
+
    localProjects !== "error"
+
      ? await getProjectsListingData(localProjects ?? [])
+
      : ("error" as const),
+
    seedProjects !== "error"
+
      ? await getProjectsListingData(seedProjects)
+
      : ("error" as const),
+
  ]);
+

+
  const nodeId = connectedToLocalNode ? (await api.getNode()).id : undefined;
+

+
  return {
+
    resource: "home",
+
    params: {
+
      localProjects: projectsWithListingData[0],
+
      preferredSeedProjects: projectsWithListingData[1],
+
      preferredSeed: preferredSeeds.selected,
+
      nodeId,
+
    },
+
  };
}
modified src/views/nodes/View.svelte
@@ -1,24 +1,23 @@
<script lang="ts">
  import type { BaseUrl } from "@httpd-client";
-
  import type { ProjectActivity } from "@app/views/nodes/router";
-

  import { isLocal, truncateId } from "@app/lib/utils";
  import { loadProjects } from "@app/views/nodes/router";
-

  import AppLayout from "@app/App/AppLayout.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";
  import Button from "@app/components/Button.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import { isDelegate } from "@app/lib/roles";
+
  import { api, httpdStore } from "@app/lib/httpd";
+
  import type { ProjectWithListingData } from "@app/lib/projects";

  export let baseUrl: BaseUrl;
  export let nid: string;
  export let externalAddresses: string[];
  export let projectCount: number;
  export let projectPageIndex: number;
-
  export let projects: ProjectActivity[] = [];
+
  export let projects: ProjectWithListingData[] = [];
  export let version: string;

  let error: any;
@@ -44,6 +43,11 @@
    !error &&
    projectCount &&
    projects.length < projectCount;
+

+
  $: session =
+
    $httpdStore.state === "authenticated" && isLocal(api.baseUrl.hostname)
+
      ? $httpdStore.session
+
      : undefined;
</script>

<style>
@@ -81,7 +85,7 @@
  }
  .projects {
    display: flex;
-
    gap: 2rem;
+
    gap: 1rem;
    flex-direction: column;
  }
  .more {
@@ -140,33 +144,21 @@
      </div>

      <div class="projects">
-
        {#each projects as { project, activity } (project.id)}
-
          <Link
-
            route={{
-
              resource: "project.source",
-
              project: project.id,
-
              node: baseUrl,
-
            }}>
-
            <div class="global-hide-on-mobile">
-
              <ProjectCard
-
                {activity}
-
                id={project.id}
-
                name={project.name}
-
                visibility={project.visibility?.type}
-
                description={project.description}
-
                head={project.head} />
-
            </div>
-
            <div class="global-hide-on-desktop">
-
              <ProjectCard
-
                compact
-
                {activity}
-
                id={project.id}
-
                name={project.name}
-
                visibility={project.visibility?.type}
-
                description={project.description}
-
                head={project.head} />
-
            </div>
-
          </Link>
+
        {#each projects as { project, activity, lastCommit } (project.id)}
+
          <ProjectCard
+
            compact
+
            id={project.id}
+
            name={project.name}
+
            description={project.description}
+
            {activity}
+
            {baseUrl}
+
            numberOfIssues={project.issues.open}
+
            numberOfPatches={project.patches.open}
+
            lastUpdatedTimestamp={lastCommit.commit.committer.time}
+
            isPrivate={project.visibility?.type === "private"}
+
            isSeeding={false}
+
            isDelegate={isDelegate(session?.publicKey, project.delegates) ??
+
              false} />
        {/each}
      </div>

modified src/views/nodes/router.ts
@@ -1,24 +1,19 @@
-
import type { BaseUrl, Project } from "@httpd-client";
+
import type { BaseUrl } from "@httpd-client";
import type {
  LoadErrorRoute,
  NotFoundRoute,
} from "@app/lib/router/definitions";
-
import type { WeeklyActivity } from "@app/lib/commit";
+
import type { ProjectWithListingData } from "@app/lib/projects";

import { HttpdClient } from "@httpd-client";
-
import { loadProjectActivity } from "@app/lib/commit";
import { config } from "@app/lib/config";
+
import { getProjectsListingData } from "@app/lib/projects";

export interface NodesRouteParams {
  baseUrl: BaseUrl;
  projectPageIndex: number;
}

-
export interface ProjectActivity {
-
  project: Project;
-
  activity: WeeklyActivity[];
-
}
-

export interface NodesRoute {
  resource: "nodes";
  params: NodesRouteParams;
@@ -32,7 +27,7 @@ export interface NodesLoadedRoute {
    version: string;
    externalAddresses: string[];
    nid: string;
-
    projects: ProjectActivity[];
+
    projects: ProjectWithListingData[];
    projectCount: number;
  };
}
@@ -44,7 +39,7 @@ export async function loadProjects(
  baseUrl: BaseUrl,
): Promise<{
  total: number;
-
  projects: ProjectActivity[];
+
  projects: ProjectWithListingData[];
}> {
  const api = new HttpdClient(baseUrl);

@@ -53,23 +48,8 @@ export async function loadProjects(
    api.project.getAll({ page, perPage: PROJECTS_PER_PAGE, show: "all" }),
  ]);

-
  const results = await Promise.all(
-
    projects.map(async project => {
-
      let activity: WeeklyActivity[] = [];
-
      try {
-
        activity = await loadProjectActivity(project.id, baseUrl);
-
      } catch (error) {
-
        console.error(
-
          `Failed to obtain project activity for: ${project.id}`,
-
          error,
-
        );
-
      }
-

-
      return {
-
        project,
-
        activity,
-
      };
-
    }),
+
  const results = await getProjectsListingData(
+
    projects.map(p => ({ project: p, baseUrl })),
  );

  return {
modified src/views/projects/Source/PeerSelector.svelte
@@ -57,7 +57,10 @@
        alias={selectedPeer.alias}
        stylePopoverPositionLeft="-0.75rem" />
      {#if selectedPeer.delegate}
-
        <Badge size="tiny" variant="secondary">delegate</Badge>
+
        <Badge size="tiny" variant="primary">
+
          <IconSmall name="badge" />
+
          Delegate
+
        </Badge>
      {/if}
    {:else}
      Remotes
@@ -74,13 +77,16 @@
        <DropdownListItem
          selected={item.selected}
          title={createTitle(item.remote)}>
-
          <Avatar nodeId={item.remote.id} />
+
          <div style:height="1rem"><Avatar nodeId={item.remote.id} /></div>
          <span style:font-family="var(--font-family-monospace)">
            {item.remote.alias || formatNodeId(item.remote.id)}
          </span>
          {#if item.remote.delegate}
            <div style:color="var(--color-fill-secondary)">
-
              <Badge size="tiny" variant="secondary">delegate</Badge>
+
              <Badge size="tiny" variant="primary">
+
                <IconSmall name="badge" />
+
                Delegate
+
              </Badge>
            </div>
          {/if}
        </DropdownListItem>
modified src/views/projects/Source/ProjectNameHeader.svelte
@@ -2,7 +2,6 @@
  import type { BaseUrl, Project } from "@httpd-client";

  import * as modal from "@app/lib/modal";
-
  import capitalize from "lodash/capitalize";
  import { httpdStore, api } from "@app/lib/httpd";
  import { twemoji } from "@app/lib/utils";

@@ -12,6 +11,7 @@
  import ErrorModal from "@app/modals/ErrorModal.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
  import Link from "@app/components/Link.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
  import SeedButton from "../Header/SeedButton.svelte";
  import Share from "@app/views/projects/Share.svelte";

@@ -114,8 +114,9 @@
      </Link>
    </span>
    {#if project.visibility && project.visibility.type === "private"}
-
      <Badge variant="yellowOutline" size="tiny">
-
        {capitalize(project.visibility.type)}
+
      <Badge variant="yellow" size="tiny">
+
        <IconSmall name="lock" />
+
        Private
      </Badge>
    {/if}
    <div
modified tests/build/smoke.spec.ts
@@ -5,9 +5,5 @@ test("exceptions in production build", async ({ page }) => {
  // Wait for scripts to finish executing, there might be exceptions that
  // happen after the page has been painted.
  await page.waitForTimeout(2000);
-
  await expect(
-
    page.getByText(
-
      "Radicle enables developers to securely collaborate on software over a peer-to-peer network built on Git.",
-
    ),
-
  ).toBeVisible();
+
  await expect(page.getByText("Local projects")).toBeVisible();
});
modified tests/e2e/hashRouter.spec.ts
@@ -28,7 +28,7 @@ test("navigation between node and project pages", async ({ page }) => {
  await page.goto("/#/nodes/radicle.local");

  const project = page
-
    .locator(".project", { hasText: "source-browsing" })
+
    .locator(".project-card", { hasText: "source-browsing" })
    .nth(0);
  await project.click();
  await expect(page).toHaveURL(`/#${sourceBrowsingUrl}`);
modified tests/e2e/historyRouter.spec.ts
@@ -29,7 +29,7 @@ test("navigation between node and project pages", async ({ page }) => {
  await page.goto("/nodes/radicle.local");

  const project = page
-
    .locator(".project", { hasText: "source-browsing" })
+
    .locator(".project-card", { hasText: "source-browsing" })
    .nth(0);
  await project.click();
  await expect(page).toHaveURL(sourceBrowsingUrl);
modified tests/e2e/landingPage.spec.ts
@@ -1,9 +1,4 @@
-
import {
-
  aliceMainHead,
-
  appConfigWithFixture,
-
  expect,
-
  test,
-
} from "@tests/support/fixtures.js";
+
import { appConfigWithFixture, expect, test } from "@tests/support/fixtures.js";

test.use({
  customAppConfig: true,
@@ -12,9 +7,7 @@ test.use({
test("show pinned projects", async ({ page }) => {
  await page.addInitScript(appConfigWithFixture);
  await page.goto("/");
-
  await expect(
-
    page.getByText("Explore projects on your local node."),
-
  ).toBeVisible();
+
  await expect(page.getByText("Local projects")).toBeVisible();

  // Shows pinned project name.
  await expect(page.getByText("source-browsing")).toBeVisible();
@@ -23,7 +16,4 @@ test("show pinned projects", async ({ page }) => {
  await expect(
    page.getByText("Git repository for source browsing tests"),
  ).toBeVisible();
-

-
  // Shows latest commit.
-
  await expect(page.getByText(aliceMainHead.substring(0, 7))).toBeVisible();
});
modified tests/e2e/node.spec.ts
@@ -1,10 +1,4 @@
-
import {
-
  aliceMainHead,
-
  expect,
-
  shortNodeRemote,
-
  sourceBrowsingRid,
-
  test,
-
} from "@tests/support/fixtures.js";
+
import { expect, shortNodeRemote, test } from "@tests/support/fixtures.js";
import { createProject } from "@tests/support/project";

test("node metadata", async ({ page, peerManager }) => {
@@ -33,7 +27,7 @@ test("node metadata", async ({ page, peerManager }) => {
test("node projects", async ({ page }) => {
  await page.goto("/nodes/radicle.local");
  const project = page
-
    .locator(".project", { hasText: "source-browsing" })
+
    .locator(".project-card", { hasText: "source-browsing" })
    .nth(0);

  // Project metadata.
@@ -42,14 +36,6 @@ test("node projects", async ({ page }) => {
    await expect(
      project.getByText("Git repository for source browsing tests"),
    ).toBeVisible();
-
    await expect(project.getByText(aliceMainHead)).toBeVisible();
-
  }
-

-
  // Show project ID on hover.
-
  {
-
    await expect(project.getByText(sourceBrowsingRid)).not.toBeVisible();
-
    await project.hover();
-
    await expect(project.getByText(sourceBrowsingRid)).toBeVisible();
  }
});

modified tests/e2e/project.spec.ts
@@ -278,7 +278,7 @@ test("peer and branch switching", async ({ page }) => {
        name: "alice delegate",
      })
      .click();
-
    await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
+
    await expect(page.getByTitle("Change peer")).toHaveText("alice Delegate");

    // Default `main` branch.
    {
modified tests/e2e/project/commits.spec.ts
@@ -20,7 +20,7 @@ test("peer and branch switching", async ({ page }) => {
      })
      .click();

-
    await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
+
    await expect(page.getByTitle("Change peer")).toHaveText("alice Delegate");

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
    await expect(page.locator(".list .teaser")).toHaveCount(6);
modified tests/support/fixtures.ts
@@ -88,7 +88,11 @@ export const test = base.extend<{
                },
              ],
            },
-
            projects: { pinned: [] },
+
            fallbackPreferredSeed: {
+
              hostname: "seed.radicle.garden",
+
              port: 443,
+
              scheme: "https",
+
            },
          };
        });
      }
@@ -215,36 +219,10 @@ export function appConfigWithFixture(defaultLocalHttpdPort = 8081) {
        },
      ],
    },
-
    projects: {
-
      pinned: [
-
        {
-
          name: "cobs",
-
          id: "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe",
-
          baseUrl: {
-
            hostname: "127.0.0.1",
-
            port: 8081,
-
            scheme: "http",
-
          },
-
        },
-
        {
-
          name: "markdown",
-
          id: "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg",
-
          baseUrl: {
-
            hostname: "127.0.0.1",
-
            port: 8081,
-
            scheme: "http",
-
          },
-
        },
-
        {
-
          name: "source-browsing",
-
          id: "rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir",
-
          baseUrl: {
-
            hostname: "127.0.0.1",
-
            port: 8081,
-
            scheme: "http",
-
          },
-
        },
-
      ],
+
    fallbackPreferredSeed: {
+
      hostname: "seed.radicle.garden",
+
      port: 443,
+
      scheme: "https",
    },
  };
}