Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Redesign node view
Merged rudolfs opened 1 year ago

Switch to a layout with a sidebar and a bigger header. Also show a background image in the header and the node name and title.

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

53 files changed +1271 -1241 266afb17 c0049f83
modified config/custom-environment-variables.json
@@ -3,13 +3,7 @@
    "fallbackPublicExplorer": "FALLBACK_PUBLIC_EXPLORER",
    "apiVersion": "API_VERSION",
    "defaultHttpdPort": "DEFAULT_HTTPD_PORT",
-
    "defaultHttpdHostname": "DEFAULT_HTTPD_HOSTNAME",
-
    "defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME",
-
    "defaultNodePort": "DEFAULT_NODE_PORT",
-
    "pinned": {
-
      "__name": "PINNED_NODES",
-
      "__format": "json"
-
    }
+
    "defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME"
  },
  "source": {
    "commitsPerPage": "COMMITS_PER_PAGE"
modified config/default.json
@@ -3,18 +3,7 @@
    "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
    "apiVersion": "4.0.0",
    "defaultHttpdPort": 443,
-
    "defaultHttpdHostname": "seed.radicle.garden",
-
    "defaultHttpdScheme": "https",
-
    "defaultNodePort": 8776,
-
    "pinned": [
-
      {
-
        "baseUrl": {
-
          "hostname": "seed.radicle.xyz",
-
          "port": 443,
-
          "scheme": "https"
-
        }
-
      }
-
    ]
+
    "defaultHttpdScheme": "https"
  },
  "source": {
    "commitsPerPage": 30
modified config/test.json
@@ -1,16 +1,11 @@
{
  "nodes": {
    "defaultHttpdPort": 8081,
-
    "defaultHttpdHostname": "127.0.0.1",
-
    "defaultHttpdScheme": "http",
-
    "pinned": [
-
      {
-
        "baseUrl": {
-
          "hostname": "127.0.0.1",
-
          "port": 8081,
-
          "scheme": "http"
-
        }
-
      }
-
    ]
+
    "defaultHttpdScheme": "http"
+
  },
+
  "fallbackPreferredSeed": {
+
    "hostname": "127.0.0.1",
+
    "port": 8081,
+
    "scheme": "http"
  }
}
modified module.d.ts
@@ -4,11 +4,8 @@ declare module "virtual:*" {
      apiVersion: string;
      fallbackPublicExplorer: string;
      defaultHttpdPort: number;
-
      defaultHttpdHostname: string;
      defaultLocalHttpdPort: number;
-
      defaultNodePort: number;
      defaultHttpdScheme: string;
-
      pinned: { baseUrl: BaseUrl }[];
    };
    source: {
      commitsPerPage: number;
added public/images/default-seed-avatar.png
added public/images/default-seed-header.png
modified radicle-httpd/src/api/v1/node.rs
@@ -15,7 +15,7 @@ use radicle::Node;

use crate::api::error::Error;
use crate::api::Context;
-
use crate::axum_extra::Path;
+
use crate::axum_extra::{cached_response, Path};

pub fn router(ctx: Context) -> Router {
    Router::new()
@@ -84,13 +84,15 @@ async fn node_handler(State(ctx): State<Context>) -> impl IntoResponse {
        }
    };

-
    Ok::<_, Error>(Json(Response::new(
+
    let response = Response::new(
        node_id,
        agent,
        config,
        node_state.to_string(),
        ctx.profile.config.web.clone(),
-
    )))
+
    );
+

+
    Ok::<_, Error>(cached_response(response, 600))
}

/// Return stored information about other nodes.
modified src/App.svelte
@@ -12,7 +12,6 @@

  import Commit from "@app/views/projects/Commit.svelte";
  import History from "@app/views/projects/History.svelte";
-
  import Home from "@app/views/home/Index.svelte";
  import Issue from "@app/views/projects/Issue.svelte";
  import Issues from "@app/views/projects/Issues.svelte";
  import Nodes from "@app/views/nodes/View.svelte";
@@ -59,8 +58,6 @@
  <div class="loading">
    <Loading />
  </div>
-
{:else if $activeRouteStore.resource === "home"}
-
  <Home />
{:else if $activeRouteStore.resource === "nodes"}
  <Nodes {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.source"}
deleted src/App/AppLayout.svelte
@@ -1,47 +0,0 @@
-
<script lang="ts">
-
  import Footer from "@app/App/Footer.svelte";
-
  import Header from "@app/App/Header.svelte";
-
  import MobileFooter from "@app/App/MobileFooter.svelte";
-
</script>
-

-
<style>
-
  .app {
-
    display: flex;
-
    flex-direction: column;
-
    height: 100%;
-
  }
-
  .header {
-
    border-bottom: 1px solid var(--color-fill-separator);
-
  }
-
  .content {
-
    height: 100%;
-
    overflow-y: scroll;
-
  }
-
  @media (max-width: 719.98px) {
-
    .app {
-
      display: grid;
-
      grid-template-rows: 1fr auto;
-
      height: 100%;
-
    }
-
    .content {
-
      overflow-y: scroll;
-
    }
-
  }
-
</style>
-

-
<div class="app">
-
  <div class="global-hide-on-mobile-down header">
-
    <Header />
-
  </div>
-
  <div class="content">
-
    <slot />
-
  </div>
-
  <div style:margin-top="auto">
-
    <div class="global-hide-on-mobile-down">
-
      <Footer />
-
    </div>
-
    <div class="global-hide-on-small-desktop-up">
-
      <MobileFooter />
-
    </div>
-
  </div>
-
</div>
deleted src/App/Header.svelte
@@ -1,34 +0,0 @@
-
<script lang="ts">
-
  import Breadcrumbs from "./Header/Breadcrumbs.svelte";
-
  import Link from "@app/components/Link.svelte";
-
</script>
-

-
<style>
-
  header {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
    margin: 0;
-
    padding: 0.5rem 1rem;
-
    height: 3.5rem;
-
  }
-

-
  .logo {
-
    height: var(--button-regular-height);
-
    margin: 0 0.5rem;
-
  }
-
</style>
-

-
<header>
-
  <Link
-
    style="display: flex; align-items: center;"
-
    route={{ resource: "home" }}>
-
    <img
-
      width="24"
-
      height="24"
-
      class="logo"
-
      alt="Radicle logo"
-
      src="/radicle.svg" />
-
  </Link>
-
  <Breadcrumbs />
-
</header>
deleted src/App/Header/Breadcrumbs.svelte
@@ -1,41 +0,0 @@
-
<script lang="ts">
-
  import * as router from "@app/lib/router";
-
  import * as utils from "@app/lib/utils";
-

-
  import NodeSegment from "./Breadcrumbs/NodeSegment.svelte";
-
  import ProjectSegment from "./Breadcrumbs/ProjectSegment.svelte";
-
  import Separator from "./Breadcrumbs/Separator.svelte";
-

-
  const activeRouteStore = router.activeRouteStore;
-
</script>
-

-
<style>
-
  .breadcrumbs {
-
    display: flex;
-
    align-items: center;
-
    column-gap: 0.25rem;
-
    line-height: 1rem;
-
    font-weight: var(--font-weight-semibold);
-
    font-size: var(--font-size-small);
-
    white-space: nowrap;
-
    flex-wrap: wrap;
-
  }
-
</style>
-

-
{#if $activeRouteStore.resource === "booting" || $activeRouteStore.resource === "home" || $activeRouteStore.resource === "error" || $activeRouteStore.resource === "notFound"}
-
  <!-- Don't render breadcrumbs for these routes. -->
-
{:else if $activeRouteStore.resource === "nodes"}
-
  <div class="breadcrumbs">
-
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} />
-
  </div>
-
{:else if $activeRouteStore.resource === "project.source" || $activeRouteStore.resource === "project.history" || $activeRouteStore.resource === "project.commit" || $activeRouteStore.resource === "project.issues" || $activeRouteStore.resource === "project.issue" || $activeRouteStore.resource === "project.patches" || $activeRouteStore.resource === "project.patch"}
-
  <div class="breadcrumbs">
-
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} />
-

-
    <Separator />
-

-
    <ProjectSegment activeRoute={$activeRouteStore} />
-
  </div>
-
{:else}
-
  {utils.unreachable($activeRouteStore)}
-
{/if}
deleted src/App/Header/Breadcrumbs/NodeSegment.svelte
@@ -1,29 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
-

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

-
  export let baseUrl: BaseUrl;
-
</script>
-

-
<style>
-
  .segment :global(a:hover) {
-
    color: var(--color-fill-secondary);
-
  }
-
</style>
-

-
<span class="segment">
-
  <Link
-
    style="display: flex; align-items: center; gap: 0.25rem;"
-
    route={{
-
      resource: "nodes",
-
      params: {
-
        baseUrl,
-
        projectPageIndex: 0,
-
      },
-
    }}>
-
    <IconSmall name="seedling" />
-
    {baseUrl.hostname}
-
  </Link>
-
</span>
deleted src/App/Header/Breadcrumbs/ProjectSegment.svelte
@@ -1,127 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectLoadedRoute } from "@app/views/projects/router";
-

-
  import { formatObjectId, unreachable } from "@app/lib/utils";
-

-
  import FilePath from "@app/components/FilePath.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import Separator from "./Separator.svelte";
-

-
  export let activeRoute: ProjectLoadedRoute;
-
</script>
-

-
<style>
-
  .segment {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
  }
-
  .segment :global(a:hover) {
-
    color: var(--color-fill-secondary);
-
  }
-
  .id {
-
    font-size: var(--font-size-small);
-
    font-family: var(--font-family-monospace);
-
    font-weight: var(--font-weight-semibold);
-
  }
-
</style>
-

-
<span class="segment">
-
  <Link
-
    route={{
-
      resource: "project.source",
-
      project: activeRoute.params.project.id,
-
      node: activeRoute.params.baseUrl,
-
    }}>
-
    <div class="segment">
-
      {#if activeRoute.params.project.visibility?.type === "private"}
-
        <IconSmall name="lock" />
-
      {/if}
-
      {activeRoute.params.project.name}
-
    </div>
-
  </Link>
-
</span>
-

-
<span class="segment">
-
  {#if activeRoute.resource === "project.history"}
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.history",
-
        project: activeRoute.params.project.id,
-
        node: activeRoute.params.baseUrl,
-
      }}>
-
      Commits
-
    </Link>
-
  {:else if activeRoute.resource === "project.commit"}
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.history",
-
        project: activeRoute.params.project.id,
-
        node: activeRoute.params.baseUrl,
-
      }}>
-
      Commits
-
    </Link>
-
  {:else if activeRoute.resource === "project.issue" || activeRoute.resource === "project.issues"}
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.issues",
-
        project: activeRoute.params.project.id,
-
        node: activeRoute.params.baseUrl,
-
      }}>
-
      Issues
-
    </Link>
-
  {:else if activeRoute.resource === "project.patch" || activeRoute.resource === "project.patches"}
-
    <Separator />
-
    <Link
-
      route={{
-
        resource: "project.patches",
-
        project: activeRoute.params.project.id,
-
        node: activeRoute.params.baseUrl,
-
      }}>
-
      Patches
-
    </Link>
-
  {:else if activeRoute.resource === "project.source"}
-
    {#if activeRoute.params.path !== "/"}
-
      <Separator />
-
      <FilePath filenameWithPath={activeRoute.params.path} />
-
    {/if}
-
  {:else}
-
    {unreachable(activeRoute)}
-
  {/if}
-
</span>
-

-
{#if activeRoute.resource === "project.commit"}
-
  <Separator />
-
  <span class="id">
-
    <div class="global-hide-on-small-desktop-down">
-
      {activeRoute.params.commit.commit.id}
-
    </div>
-
    <div class="global-hide-on-medium-desktop-up">
-
      {formatObjectId(activeRoute.params.commit.commit.id)}
-
    </div>
-
  </span>
-
{:else if activeRoute.resource === "project.issue"}
-
  <Separator />
-
  <span class="id">
-
    <div class="global-hide-on-small-desktop-down">
-
      {activeRoute.params.issue.id}
-
    </div>
-
    <div class="global-hide-on-medium-desktop-up">
-
      {formatObjectId(activeRoute.params.issue.id)}
-
    </div>
-
  </span>
-
{:else if activeRoute.resource === "project.patch"}
-
  <Separator />
-
  <span class="id">
-
    <div class="global-hide-on-small-desktop-down">
-
      {activeRoute.params.patch.id}
-
    </div>
-
    <div class="global-hide-on-medium-desktop-up">
-
      {formatObjectId(activeRoute.params.patch.id)}
-
    </div>
-
  </span>
-
{/if}
deleted src/App/Header/Breadcrumbs/Separator.svelte
@@ -1,7 +0,0 @@
-
<script lang="ts">
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
</script>
-

-
<span style:color="var(--color-foreground-dim)">
-
  <IconSmall name="chevron-right" />
-
</span>
added src/App/Layout.svelte
@@ -0,0 +1,68 @@
+
<script lang="ts">
+
  import Footer from "@app/App/Footer.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
  import Link from "@app/components/Link.svelte";
+
</script>
+

+
<style>
+
  .app {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
  }
+
  .header {
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    margin: 0;
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    height: 3.5rem;
+
    justify-content: flex-end;
+
  }
+
  .content {
+
    height: 100%;
+
    overflow-y: scroll;
+
  }
+

+
  .logo {
+
    height: var(--button-regular-height);
+
    margin: 0 0.5rem;
+
  }
+
  @media (max-width: 719.98px) {
+
    .app {
+
      display: grid;
+
      grid-template-rows: 1fr auto;
+
      height: 100%;
+
    }
+
    .content {
+
      overflow-y: scroll;
+
    }
+
  }
+
</style>
+

+
<div class="app">
+
  <div class="global-hide-on-mobile-down header">
+
    <Link
+
      style="display: flex; align-items: center;"
+
      route={{ resource: "nodes", params: undefined }}>
+
      <img
+
        width="24"
+
        height="24"
+
        class="logo"
+
        alt="Radicle logo"
+
        src="/radicle.svg" />
+
    </Link>
+
  </div>
+
  <div class="content">
+
    <slot />
+
  </div>
+
  <div style:margin-top="auto">
+
    <div class="global-hide-on-mobile-down">
+
      <Footer />
+
    </div>
+
    <div class="global-hide-on-small-desktop-up">
+
      <MobileFooter />
+
    </div>
+
  </div>
+
</div>
modified src/App/MobileFooter.svelte
@@ -28,7 +28,7 @@
<div class="mobile-footer">
  <Link
    style="width: 100%; display: flex; align-items: center; justify-content: center;"
-
    route={{ resource: "home" }}>
+
    route={{ resource: "nodes", params: undefined }}>
    <img
      width="16"
      height="16"
modified src/components/Id.svelte
@@ -53,7 +53,6 @@
    gap: 0.5rem;
    justify-content: center;
    z-index: 20;
-
    bottom: 1.5rem;
    background: var(--color-fill-ghost);
    color: var(--color-fill-gray);
    border: 1px solid var(--color-border-default);
@@ -101,7 +100,7 @@
  </div>

  {#if visible}
-
    <div style:position="absolute">
+
    <div style:position="absolute" style:top="-2rem">
      <div class="popover">
        <IconSmall name={icon} />
        {tooltip}
modified src/components/ScopePolicyExplainer.svelte
@@ -10,19 +10,31 @@

<style>
  .section {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    padding: 0.5rem 0;
+
    padding-bottom: 0.5rem;
+
  }
+
  .text {
+
    padding-right: 2rem;
  }
</style>

+
<div class="section" style:padding-top="0.5rem">
+
  Policy:
+
  <span class="txt-bold">{capitalize(policy)}</span>
+
</div>
+
<div class="txt-missing text">
+
  {#if policy === "allow"}
+
    All discovered repositories will be seeded.
+
  {:else if policy === "block"}
+
    Only selected repositories will be seeded.
+
  {/if}
+
</div>
+

{#if policy === "allow"}
-
  <div class="section">
+
  <div class="section" style:padding-top="0.5rem">
    Scope:
    <span class="txt-bold">{capitalize(scope)}</span>
  </div>
-
  <div class="txt-missing">
+
  <div class="txt-missing text">
    {#if scope === "all"}
      All changes in seeded repositories, made by any peer, will be synced.
    {:else if scope === "followed"}
@@ -30,15 +42,3 @@
    {/if}
  </div>
{/if}
-

-
<div class="section">
-
  Policy:
-
  <span class="txt-bold">{capitalize(policy)}</span>
-
</div>
-
<div class="txt-missing">
-
  {#if policy === "allow"}
-
    All discovered repositories will get seeded.
-
  {:else if policy === "block"}
-
    Only repositories marked as such will get seeded.
-
  {/if}
-
</div>
modified src/lib/router.ts
@@ -107,7 +107,7 @@ async function navigate(
function setTitle(loadedRoute: LoadedRoute) {
  const title: string[] = [];

-
  if (loadedRoute.resource === "booting" || loadedRoute.resource === "home") {
+
  if (loadedRoute.resource === "booting") {
    title.push("Radicle");
  } else if (loadedRoute.resource === "error") {
    title.push("Error");
@@ -194,11 +194,15 @@ function urlToRoute(url: URL): Route | null {
            params: { baseUrl, projectPageIndex: 0 },
          };
        }
+
      } else {
+
        return {
+
          resource: "nodes",
+
          params: undefined,
+
        };
      }
-
      return null;
    }
    case "": {
-
      return { resource: "home" };
+
      return { resource: "nodes", params: undefined };
    }
    default: {
      return null;
@@ -207,10 +211,12 @@ function urlToRoute(url: URL): Route | null {
}

export function routeToPath(route: Route): string {
-
  if (route.resource === "home") {
-
    return "/";
-
  } else if (route.resource === "nodes") {
-
    return nodePath(route.params.baseUrl);
+
  if (route.resource === "nodes") {
+
    if (route.params === undefined) {
+
      return "/";
+
    } else {
+
      return nodePath(route.params.baseUrl);
+
    }
  } else if (
    route.resource === "project.source" ||
    route.resource === "project.history" ||
modified src/lib/router/definitions.ts
@@ -2,7 +2,6 @@ import type {
  ResponseError,
  ResponseParseError,
} from "@http-client/lib/fetcher";
-
import type { HomeRoute, HomeLoadedRoute } from "@app/views/home/router";
import type {
  ProjectLoadedRoute,
  ProjectRoute,
@@ -37,7 +36,6 @@ export interface ErrorRoute {

export type Route =
  | BootingRoute
-
  | HomeRoute
  | ErrorRoute
  | NotFoundRoute
  | ProjectRoute
@@ -45,7 +43,6 @@ export type Route =

export type LoadedRoute =
  | BootingRoute
-
  | HomeLoadedRoute
  | ErrorRoute
  | NotFoundRoute
  | ProjectLoadedRoute
@@ -57,8 +54,6 @@ export async function loadRoute(
): Promise<LoadedRoute> {
  if (route.resource === "nodes") {
    return await loadNodeRoute(route.params);
-
  } else if (route.resource === "home") {
-
    return { resource: "home" };
  } else if (
    route.resource === "project.source" ||
    route.resource === "project.history" ||
modified src/lib/utils.ts
@@ -8,11 +8,6 @@ export async function toClipboard(text: string): Promise<void> {
  await navigator.clipboard.writeText(text);
}

-
// Removes the first and last character which are always `/`.
-
export function formatUserAgent(agent: string): string {
-
  return agent.slice(1, -1);
-
}
-

export function formatInlineTitle(input: string): string {
  return input.replaceAll(/`([^`]+)`/g, "<code>$1</code>");
}
modified src/views/NotFound.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import AppLayout from "@app/App/AppLayout.svelte";
+
  import Layout from "@app/App/Layout.svelte";
  import Icon from "@app/components/Icon.svelte";

  export let title: string;
@@ -16,9 +16,9 @@
  }
</style>

-
<AppLayout>
+
<Layout>
  <div class="container">
    <Icon name="desert" size="48" />
    <div class="title txt-medium txt-bold">{title}</div>
  </div>
-
</AppLayout>
+
</Layout>
modified src/views/error/View.svelte
@@ -3,7 +3,7 @@
  import type Icon from "@app/components/Icon.svelte";
  import type { ErrorParam } from "@app/lib/router/definitions";

-
  import AppLayout from "@app/App/AppLayout.svelte";
+
  import Layout from "@app/App/Layout.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";

  export let icon: ComponentProps<Icon>["name"] = "desert";
@@ -32,10 +32,10 @@
  }
</style>

-
<AppLayout>
+
<Layout>
  <div class="wrapper">
    <div class="container">
      <ErrorMessage {icon} {title} {description} {error} />
    </div>
  </div>
-
</AppLayout>
+
</Layout>
deleted src/views/home/Index.svelte
@@ -1,127 +0,0 @@
-
<script lang="ts">
-
  import type { ComponentProps } from "svelte";
-
  import type { ProjectInfo } from "@app/components/ProjectCard";
-

-
  import { derived } from "svelte/store";
-

-
  import { baseUrlToString } from "@app/lib/utils";
-
  import { deduplicateStore } from "@app/lib/deduplicateStore";
-
  import { fetchProjectInfos } from "@app/components/ProjectCard";
-
  import { handleError } from "@app/views/home/error";
-
  import { preferredSeeds } from "@app/lib/seeds";
-

-
  import AppLayout from "@app/App/AppLayout.svelte";
-
  import ProjectCard from "@app/components/ProjectCard.svelte";
-

-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import HomepageSection from "./components/HomepageSection.svelte";
-
  import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
-

-
  const selectedSeed = deduplicateStore(
-
    derived(preferredSeeds, $ => $?.selected),
-
  );
-

-
  let preferredSeedProjects:
-
    | ProjectInfo[]
-
    | ComponentProps<ErrorMessage>["error"]
-
    | undefined;
-

-
  async function loadPreferredSeedProjects() {
-
    preferredSeedProjects = undefined;
-

-
    if (!$selectedSeed) return;
-
    preferredSeedProjects = await fetchProjectInfos($selectedSeed, {
-
      show: "pinned",
-
    }).catch(error => error);
-
  }
-

-
  $: $selectedSeed && void loadPreferredSeedProjects();
-
</script>
-

-
<style>
-
  .wrapper {
-
    padding: 3rem;
-
    max-width: 78rem;
-
    margin: 0 auto;
-
    width: 100%;
-
    display: flex;
-
    flex-direction: column;
-
    gap: 3rem;
-
  }
-
  .empty-state {
-
    text-align: center;
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .empty-state .heading {
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-bold);
-
  }
-
  .empty-state .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
  }
-
  .flex-icon-item {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  .project-grid {
-
    display: grid;
-
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
-
    gap: 1rem;
-
  }
-

-
  @media (max-width: 719.98px) {
-
    .wrapper {
-
      width: 100%;
-
      padding: 1rem;
-
    }
-
  }
-
</style>
-

-
<AppLayout>
-
  <div class="wrapper" style:padding-bottom="2.5rem">
-
    <HomepageSection
-
      loading={preferredSeedProjects === undefined}
-
      empty={preferredSeedProjects instanceof Error ||
-
        preferredSeedProjects?.length === 0}
-
      title="Explore">
-
      <svelte:fragment slot="title">
-
        <div class="flex-icon-item" style:min-width="0">
-
          <span class="txt-large">Explore</span>
-
          <PreferredSeedDropdown selectedSeed={$preferredSeeds.selected} />
-
        </div>
-
      </svelte:fragment>
-
      <svelte:fragment slot="subtitle">
-
        Pinned repositories on your selected seed node
-
      </svelte:fragment>
-
      <svelte:fragment slot="empty">
-
        <div class="empty-state">
-
          {#if preferredSeedProjects instanceof Error}
-
            <ErrorMessage
-
              {...handleError(
-
                preferredSeedProjects,
-
                baseUrlToString($preferredSeeds.selected),
-
              )} />
-
          {:else}
-
            <div class="heading">No pinned repositories</div>
-
            <div class="label">
-
              The selected seed node doesn't have any pinned repositories.
-
            </div>
-
          {/if}
-
        </div>
-
      </svelte:fragment>
-
      <div class="project-grid">
-
        {#if preferredSeedProjects && !(preferredSeedProjects instanceof Error)}
-
          {#each preferredSeedProjects as projectInfo}
-
            <ProjectCard {projectInfo} />
-
          {/each}
-
        {/if}
-
      </div>
-
    </HomepageSection>
-
  </div>
-
</AppLayout>
deleted src/views/home/components/HomepageSection.svelte
@@ -1,89 +0,0 @@
-
<script lang="ts">
-
  import Loading from "@app/components/Loading.svelte";
-

-
  export let title: string;
-
  export let loading = false;
-
  export let empty: boolean = false;
-
</script>
-

-
<style>
-
  .section-header {
-
    margin-bottom: 1.5rem;
-
  }
-
  .title {
-
    width: 100%;
-
    display: flex;
-
    align-items: center;
-
  }
-
  .title > * {
-
    margin: 0;
-
  }
-

-
  .subtitle {
-
    max-width: 100%;
-
    display: flex;
-
    flex-wrap: wrap;
-
    gap: 0.25rem;
-
    align-items: center;
-
    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: 36rem;
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
  }
-
</style>
-

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

-
  {#if loading}
-
    <div class="empty-container">
-
      <div class="inner">
-
        <Loading small />
-
      </div>
-
    </div>
-
  {:else if empty}
-
    <div class="empty-container">
-
      <div class="inner">
-
        <slot name="empty" />
-
      </div>
-
    </div>
-
  {:else}
-
    <div>
-
      <slot />
-
    </div>
-
  {/if}
-
</section>
deleted src/views/home/components/PreferredSeedDropdown.svelte
@@ -1,190 +0,0 @@
-
<script lang="ts">
-
  import { HttpdClient, type BaseUrl } from "@http-client";
-

-
  import config from "virtual:config";
-
  import {
-
    addSeedsToConfiguredSeeds,
-
    configuredPreferredSeeds,
-
    preferredSeeds as preferredSeedsStore,
-
    removeSeedFromConfiguredSeeds,
-
    selectPreferredSeed,
-
  } from "@app/lib/seeds";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  export let selectedSeed: BaseUrl;
-

-
  const validateInput = async (seed: BaseUrl) => {
-
    if (stateOptions.find(s => s.hostname === seed.hostname)) {
-
      validationMessage = "Seed node already added.";
-
      return false;
-
    }
-
    const api = new HttpdClient(seed);
-
    try {
-
      await api.getNode();
-
      return true;
-
    } catch (e) {
-
      validationMessage = "Seed node isn't reachable";
-
      return false;
-
    }
-
  };
-

-
  // Reset state if inputValue changes
-
  $: {
-
    customSeed;
-
    submittingInput = false;
-
    validationMessage = undefined;
-
    valid = true;
-
  }
-
  $: stateOptions = $preferredSeedsStore.seeds;
-
  let valid = true;
-
  let submittingInput = false;
-
  let validationMessage: undefined | string = undefined;
-
  let customSeed: string = "";
-
  let expanded = false;
-
</script>
-

-
<style>
-
  .popover {
-
    display: flex;
-
    flex-direction: column;
-
  }
-

-
  .validation-message {
-
    color: var(--color-foreground-red);
-
    margin-top: 0.5rem;
-
    margin-left: 0.5rem;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
  }
-

-
  .dropdown-item {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    width: 100%;
-
  }
-

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

-
  .icon-item {
-
    display: flex;
-
    gap: 0.5rem;
-
    align-items: center;
-
  }
-
</style>
-

-
<Popover
-
  bind:expanded
-
  popoverContainerMinWidth="0"
-
  popoverPositionTop="2.5rem"
-
  popoverPositionLeft="-0.25rem"
-
  popoverPadding="0.25rem"
-
  popoverBorderRadius="var(--border-radius-small)">
-
  <div
-
    class="icon-item"
-
    slot="toggle"
-
    title="Switch preferred seeds"
-
    let:toggle>
-
    <div class="txt-large txt-bold txt-overflow">{selectedSeed.hostname}</div>
-
    <IconButton on:click={toggle}>
-
      <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
    </IconButton>
-
  </div>
-

-
  <svelte:fragment slot="popover">
-
    <div style:width="16rem">
-
      <TextInput
-
        {valid}
-
        name="seed"
-
        bind:value={customSeed}
-
        loading={submittingInput}
-
        placeholder="Navigate to seed URL"
-
        on:submit={async () => {
-
          submittingInput = true;
-
          const customSeedBaseUrl = {
-
            hostname: customSeed,
-
            port: config.nodes.defaultHttpdPort,
-
            scheme: config.nodes.defaultHttpdScheme,
-
          };
-
          valid = await validateInput(customSeedBaseUrl);
-
          if (valid) {
-
            addSeedsToConfiguredSeeds(
-
              $configuredPreferredSeeds.length === 0
-
                ? [customSeedBaseUrl, config.fallbackPreferredSeed]
-
                : [customSeedBaseUrl],
-
            );
-
            selectPreferredSeed(customSeedBaseUrl);
-
            customSeed = "";
-
            closeFocused();
-
          } else {
-
            submittingInput = false;
-
          }
-
        }} />
-
      {#if validationMessage}
-
        <span class="validation-message txt-small">{validationMessage}</span>
-
      {/if}
-
      <div class="divider" />
-
      <div class="popover">
-
        {#if stateOptions}
-
          <DropdownList items={stateOptions}>
-
            <DropdownListItem
-
              let:item
-
              on:click={() => {
-
                selectPreferredSeed(item);
-
                closeFocused();
-
              }}
-
              slot="item"
-
              selected={item.hostname === selectedSeed.hostname}>
-
              <div class="dropdown-item">
-
                <div class="icon-item" style:min-width="0">
-
                  <IconSmall name="seedling" />
-
                  <div class="txt-overflow">
-
                    {item.hostname}
-
                  </div>
-
                </div>
-
                {#if stateOptions && stateOptions.length > 1}
-
                  <IconButton
-
                    on:click={() => {
-
                      removeSeedFromConfiguredSeeds(item.hostname);
-
                      selectPreferredSeed(config.fallbackPreferredSeed);
-
                    }}>
-
                    <IconSmall name="cross" />
-
                  </IconButton>
-
                {/if}
-
              </div>
-
            </DropdownListItem>
-
            <DropdownListItem
-
              on:click={() => {
-
                selectPreferredSeed(config.fallbackPreferredSeed);
-
                closeFocused();
-
              }}
-
              slot="empty"
-
              selected>
-
              <div class="dropdown-item">
-
                <div class="icon-item" style:min-width="0">
-
                  <IconSmall name="seedling" />
-
                  <div class="txt-overflow">
-
                    {config.fallbackPreferredSeed.hostname}
-
                  </div>
-
                </div>
-
              </div>
-
            </DropdownListItem>
-
          </DropdownList>
-
        {/if}
-
      </div>
-
    </div>
-
  </svelte:fragment>
-
</Popover>
deleted src/views/home/error.ts
@@ -1,40 +0,0 @@
-
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
-

-
export function handleError(
-
  error: Error | ResponseParseError | ResponseError,
-
  url: string,
-
): {
-
  error: Error | ResponseParseError | ResponseError;
-
  title: string;
-
  description: string;
-
} {
-
  if (error instanceof ResponseParseError) {
-
    return {
-
      error,
-
      title: "Could not parse the request",
-
      description: error.description,
-
    };
-
  } else if (error instanceof ResponseError) {
-
    return {
-
      error,
-
      title: "Could not load the repositories",
-
      description: `You're trying to fetch repositories from a node that is not reachable, make sure the address <a href="${url}">${url}</a> is correct and the right ports are exposed if its your node.`,
-
    };
-
  } else if (
-
    error instanceof TypeError &&
-
    error.message === "Failed to fetch"
-
  ) {
-
    return {
-
      error,
-
      title: "Resource not found",
-
      description: `You're trying to fetch a resource that is not available. Check that the ids is correct, and try to run <code>$ rad sync</code>.`,
-
    };
-
  } else {
-
    return {
-
      error,
-
      title: "Could not load this view",
-
      description:
-
        "You stumbled on an unknown error, we aren't exactly sure what happened.",
-
    };
-
  }
-
}
deleted src/views/home/router.ts
@@ -1,5 +0,0 @@
-
export interface HomeRoute {
-
  resource: "home";
-
}
-

-
export interface HomeLoadedRoute extends HomeRoute {}
added src/views/nodes/NodeAddress.svelte
@@ -0,0 +1,23 @@
+
<script lang="ts">
+
  import type { Node } from "@http-client";
+

+
  import { truncateId } from "@app/lib/utils";
+
  import Id from "@app/components/Id.svelte";
+

+
  export let node: Node;
+

+
  $: clipboard = node.config?.externalAddresses
+
    ? `${node.id}@${node.config.externalAddresses[0]}`
+
    : node.id;
+
</script>
+

+
<div style:word-break="break-word">
+
  <!--prettier-ignore-->
+
  <Id ariaLabel="node-id" shorten={false} id={clipboard} {clipboard}>
+
    {#if node.config?.externalAddresses.length}
+
      {truncateId(node.id)}@<wbr />{node.config?.externalAddresses[0]}
+
    {:else}
+
      {truncateId(node.id)}
+
    {/if}
+
  </Id>
+
</div>
added src/views/nodes/PolicyExplainer.svelte
@@ -0,0 +1,55 @@
+
<script lang="ts">
+
  import type { DefaultSeedingPolicy } from "@http-client";
+

+
  import { capitalize } from "lodash";
+

+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.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";
+
</script>
+

+
<style>
+
  .policies {
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
  }
+
  .item {
+
    display: flex;
+
    flex-wrap: nowrap;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
  }
+
</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-bold">
+
        {capitalize(shortScope)}
+
      </div>
+
      <IconButton on:click={() => (expandedNode = !expandedNode)}>
+
        <IconSmall name={`chevron-${expandedNode ? "up" : "down"}`} />
+
      </IconButton>
+
    </div>
+
  </div>
+
  {#if expandedNode && seedingPolicy}
+
    <div style:padding-bottom="1rem">
+
      <ScopePolicyExplainer {seedingPolicy} />
+
    </div>
+
  {/if}
+
</div>
added src/views/nodes/PreferredSeedDropdown.svelte
@@ -0,0 +1,213 @@
+
<script lang="ts">
+
  import { HttpdClient, type BaseUrl } from "@http-client";
+

+
  import { derived } from "svelte/store";
+

+
  import config from "virtual:config";
+
  import {
+
    addSeedsToConfiguredSeeds,
+
    configuredPreferredSeeds,
+
    preferredSeeds,
+
    removeSeedFromConfiguredSeeds,
+
    selectPreferredSeed,
+
  } from "@app/lib/seeds";
+
  import { closeFocused } from "@app/components/Popover.svelte";
+
  import { deduplicateStore } from "@app/lib/deduplicateStore";
+
  import { push } from "@app/lib/router";
+

+
  import DropdownList from "@app/components/DropdownList.svelte";
+
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
+
  import IconButton from "@app/components/IconButton.svelte";
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  const selectedSeed = deduplicateStore(
+
    derived(preferredSeeds, $ => $?.selected),
+
  );
+

+
  const validateInput = async (seed: BaseUrl) => {
+
    if (stateOptions.find(s => s.hostname === seed.hostname)) {
+
      validationMessage = "Seed node already added.";
+
      return false;
+
    }
+
    const api = new HttpdClient(seed);
+
    try {
+
      await api.getNode();
+
      return true;
+
    } catch (e) {
+
      validationMessage = "Seed node isn't reachable";
+
      return false;
+
    }
+
  };
+

+
  // Reset state if inputValue changes
+
  $: {
+
    customSeed;
+
    submittingInput = false;
+
    validationMessage = undefined;
+
    valid = true;
+
  }
+
  $: stateOptions = $preferredSeeds.seeds;
+
  let valid = true;
+
  let submittingInput = false;
+
  let validationMessage: undefined | string = undefined;
+
  let customSeed: string = "";
+
  let expanded = false;
+
</script>
+

+
<style>
+
  .popover {
+
    display: flex;
+
    flex-direction: column;
+
  }
+

+
  .validation-message {
+
    color: var(--color-foreground-red);
+
    margin-top: 0.5rem;
+
    margin-left: 0.5rem;
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+

+
  .dropdown-item {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    width: 100%;
+
  }
+

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

+
  .icon-item {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
  }
+
</style>
+

+
<Popover
+
  bind:expanded
+
  popoverContainerMinWidth="0"
+
  popoverPositionTop="2.5rem"
+
  popoverPositionLeft="0"
+
  popoverPadding="0.25rem"
+
  popoverBorderRadius="var(--border-radius-small)">
+
  <div
+
    class="icon-item"
+
    slot="toggle"
+
    title="Switch preferred seeds"
+
    let:toggle>
+
    <slot />
+
    <IconButton on:click={toggle}>
+
      <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
+
    </IconButton>
+
  </div>
+

+
  <svelte:fragment slot="popover">
+
    <div style:width="16rem">
+
      <TextInput
+
        {valid}
+
        name="seed"
+
        bind:value={customSeed}
+
        loading={submittingInput}
+
        placeholder="Navigate to seed"
+
        on:submit={async () => {
+
          submittingInput = true;
+
          const customSeedBaseUrl = {
+
            hostname: customSeed,
+
            port: config.nodes.defaultHttpdPort,
+
            scheme: config.nodes.defaultHttpdScheme,
+
          };
+
          valid = await validateInput(customSeedBaseUrl);
+
          if (valid) {
+
            addSeedsToConfiguredSeeds(
+
              $configuredPreferredSeeds.length === 0
+
                ? [customSeedBaseUrl, config.fallbackPreferredSeed]
+
                : [customSeedBaseUrl],
+
            );
+
            selectPreferredSeed(customSeedBaseUrl);
+
            customSeed = "";
+
            closeFocused();
+
            void push({
+
              resource: "nodes",
+
              params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
+
            });
+
          } else {
+
            submittingInput = false;
+
          }
+
        }} />
+
      {#if validationMessage}
+
        <span class="validation-message txt-small">{validationMessage}</span>
+
      {/if}
+
      <div class="divider" />
+
      <div class="popover">
+
        {#if stateOptions}
+
          <DropdownList items={stateOptions}>
+
            <DropdownListItem
+
              let:item
+
              on:click={() => {
+
                selectPreferredSeed(item);
+
                closeFocused();
+
                void push({
+
                  resource: "nodes",
+
                  params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
+
                });
+
              }}
+
              slot="item"
+
              selected={item.hostname === $selectedSeed.hostname}>
+
              <div class="dropdown-item">
+
                <div class="icon-item" style:min-width="0">
+
                  <IconSmall name="seedling" />
+
                  <div class="txt-overflow">
+
                    {item.hostname}
+
                  </div>
+
                </div>
+
                {#if stateOptions && stateOptions.length > 1}
+
                  <IconButton
+
                    on:click={() => {
+
                      removeSeedFromConfiguredSeeds(item.hostname);
+
                      selectPreferredSeed(config.fallbackPreferredSeed);
+
                      closeFocused();
+
                      void push({
+
                        resource: "nodes",
+
                        params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
+
                      });
+
                    }}>
+
                    <IconSmall name="cross" />
+
                  </IconButton>
+
                {/if}
+
              </div>
+
            </DropdownListItem>
+
            <DropdownListItem
+
              on:click={() => {
+
                selectPreferredSeed(config.fallbackPreferredSeed);
+
                closeFocused();
+
                void push({
+
                  resource: "nodes",
+
                  params: { baseUrl: $selectedSeed, projectPageIndex: 0 },
+
                });
+
              }}
+
              slot="empty"
+
              selected>
+
              <div class="dropdown-item">
+
                <div class="icon-item" style:min-width="0">
+
                  <IconSmall name="seedling" />
+
                  <div class="txt-overflow">
+
                    {config.fallbackPreferredSeed.hostname}
+
                  </div>
+
                </div>
+
              </div>
+
            </DropdownListItem>
+
          </DropdownList>
+
        {/if}
+
      </div>
+
    </div>
+
  </svelte:fragment>
+
</Popover>
deleted src/views/nodes/ScopePolicyPopover.svelte
@@ -1,63 +0,0 @@
-
<script lang="ts">
-
  import type { DefaultSeedingPolicy } from "@http-client";
-

-
  import capitalize from "lodash/capitalize";
-

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

-
  export let seedingPolicy: DefaultSeedingPolicy;
-
  export let popoverPositionRight: string | undefined = undefined;
-
  export let popoverPositionLeft: string | undefined = undefined;
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    gap: 0.5rem;
-
    align-items: center;
-
  }
-
  .separator {
-
    width: 1px;
-
    background-color: var(--color-fill-separator);
-
    display: flex;
-
    height: 1rem;
-
  }
-
  .popover {
-
    width: 18rem;
-
    color: var(--color-foreground-contrast);
-
  }
-
</style>
-

-
<div class="container">
-
  <span>
-
    Policy: <span class="txt-semibold">
-
      {capitalize(seedingPolicy.default)}
-
    </span>
-
  </span>
-
  {#if seedingPolicy.default === "allow"}
-
    <span class="separator" />
-
    <span>
-
      Scope:
-
      <span class="txt-semibold">{capitalize(seedingPolicy.scope)}</span>
-
    </span>
-
  {/if}
-
  <span style:color="var(--color-fill-gray)">
-
    <Popover
-
      {popoverPositionRight}
-
      {popoverPositionLeft}
-
      popoverPositionBottom="2.5rem">
-
      <IconButton slot="toggle" let:toggle on:click={toggle}>
-
        <span style:color="var(--color-fill-gray)">
-
          <IconSmall name="info" />
-
        </span>
-
      </IconButton>
-

-
      <div slot="popover" class="popover">
-
        <ScopePolicyExplainer {seedingPolicy} />
-
      </div>
-
    </Popover>
-
  </span>
-
</div>
added src/views/nodes/Seeding.svelte
@@ -0,0 +1,28 @@
+
<script lang="ts">
+
  import IconSmall from "@app/components/IconSmall.svelte";
+

+
  export let count: number;
+

+
  const formatter = Intl.NumberFormat("en", { notation: "compact" });
+
</script>
+

+
<style>
+
  .item {
+
    display: flex;
+
    flex-wrap: nowrap;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
    width: 100%;
+
  }
+
</style>
+

+
<div
+
  class="item"
+
  style="justify-content: space-between; display: flex; text-wrap: nowrap; font-size: var(--font-size-small); ">
+
  <span>Seeding</span>
+
  <div class="global-flex-item" style:gap="0.25rem">
+
    <IconSmall name="seedling" />{formatter.format(count).toLowerCase()}
+
    <slot />
+
  </div>
+
</div>
added src/views/nodes/UserAgent.svelte
@@ -0,0 +1,43 @@
+
<script lang="ts">
+
  import Id from "@app/components/Id.svelte";
+

+
  export let agent: string;
+
</script>
+

+
<style>
+
  .agent {
+
    color: var(--color-fill-gray);
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-small);
+
    max-width: 19rem;
+
    margin-right: 2.25rem;
+
  }
+
  .item {
+
    display: flex;
+
    flex-wrap: nowrap;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
    width: 100%;
+
  }
+
  @media (max-width: 1010.98px) {
+
    .agent {
+
      max-width: 10rem;
+
    }
+
  }
+
</style>
+

+
<div class="item">
+
  <div style:white-space="nowrap">User Agent</div>
+
  <Id
+
    ariaLabel="agent"
+
    id={agent}
+
    clipboard={agent}
+
    shorten={false}
+
    style="none">
+
    <div class="agent">
+
      <div class="txt-overflow">{agent}</div>
+
    </div>
+
  </Id>
+
</div>
modified src/views/nodes/View.svelte
@@ -1,51 +1,117 @@
<script lang="ts">
-
  import type { BaseUrl, DefaultSeedingPolicy, NodeStats } from "@http-client";
-

-
  import { capitalize } from "lodash";
+
  import type { BaseUrl, Node, NodeStats } from "@http-client";

  import * as router from "@app/lib/router";
-
  import { baseUrlToString, formatUserAgent, truncateId } from "@app/lib/utils";
+
  import { baseUrlToString } from "@app/lib/utils";
  import { fetchProjectInfos } from "@app/components/ProjectCard";
  import { handleError } from "@app/views/nodes/error";

-
  import AppLayout from "@app/App/AppLayout.svelte";
+
  import Settings from "@app/App/Settings.svelte";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import Help from "@app/App/Help.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Id from "@app/components/Id.svelte";
+
  import Link from "@app/components/Link.svelte";
  import Loading from "@app/components/Loading.svelte";
+
  import MobileFooter from "@app/App/MobileFooter.svelte";
  import Popover from "@app/components/Popover.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";
-
  import ScopePolicyExplainer from "@app/components/ScopePolicyExplainer.svelte";
+

+
  import PolicyExplainer from "./PolicyExplainer.svelte";
+
  import PreferredSeedDropdown from "./PreferredSeedDropdown.svelte";
+
  import Seeding from "./Seeding.svelte";
+
  import UserAgent from "./UserAgent.svelte";
+
  import NodeAddress from "./NodeAddress.svelte";

  export let baseUrl: BaseUrl;
-
  export let nid: string;
  export let stats: NodeStats;
-
  export let externalAddresses: string[];
-
  export let seedingPolicy: DefaultSeedingPolicy | undefined = undefined;
-
  export let agent: string;
-

-
  $: shortScope =
-
    seedingPolicy?.default === "allow" && seedingPolicy?.scope === "all"
-
      ? "permissive"
-
      : "restrictive";
+
  export let node: Node;
+

+
  let scrollY: number;
+
  let top: number;
+

+
  $: if (scrollY >= 0 && scrollY < 289) {
+
    top = 288 - scrollY;
+
  } else if (scrollY >= 289) {
+
    top = 0;
+
  }
+

+
  $: background = node.bannerUrl
+
    ? `url("${node.bannerUrl}")`
+
    : `url("/images/default-seed-header.png")`;
</script>

<style>
-
  .layout {
-
    width: 100%;
-
    height: 100%;
+
  .below-header {
+
    display: grid;
+
    grid-template: auto 1fr auto / auto 1fr auto;
+
  }
+
  .breadcrumbs {
    display: flex;
-
    justify-content: center;
-
    padding: 3rem 0 5rem 0;
+
    gap: 0.5rem;
+
    flex-direction: row;
+
    align-items: center;
+
    height: 3.5rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    justify-content: flex-end;
  }
+

  .header {
+
    grid-column: 1 / 4;
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    height: 18rem;
    display: flex;
-
    gap: 0.5rem;
+
    justify-content: space-between;
    flex-direction: column;
-
    margin-bottom: 2rem;
+
    position: sticky;
+
    z-index: 5;
+
    background-color: var(--color-background-default);
+
    background-position: center;
+
    background-size: cover;
+
  }
+
  .sidebar {
+
    grid-column: 1 / 2;
+
    border-right: 1px solid var(--color-fill-separator);
+
    width: 30rem;
+
    position: fixed;
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: space-between;
+
    padding: 1.5rem;
+
    height: 100%;
+
    z-index: 1;
+
  }
+

+
  .content {
+
    grid-column: 2 / 3;
+
    margin-left: 30rem;
+
  }
+

+
  .mobile-header {
+
    height: 8rem;
+
    display: flex;
+
    align-items: flex-end;
+
    border-bottom: 1px solid var(--color-fill-separator);
+
    background-color: var(--color-background-default);
+
    background-position: center;
+
    background-size: cover;
+
  }
+

+
  .mobile-footer {
+
    display: none;
+
  }
+

+
  .container {
+
    width: 100%;
+
    display: flex;
+
    justify-content: center;
  }
  .wrapper {
-
    padding: 3rem;
+
    padding: 1.5rem;
    max-width: 78rem;
    margin: 0 auto;
    width: 100%;
@@ -53,148 +119,352 @@
    flex-direction: column;
  }

-
  .subtitle {
-
    font-size: var(--font-size-small);
+
  .sidebar-item {
    display: flex;
    align-items: center;
-
    gap: 0.5rem;
-
    width: 100%;
+
    height: 2rem;
  }
-
  .info {
-
    display: flex;
-
    justify-content: space-between;
-
  }
-
  .agent {
-
    color: var(--color-fill-gray);
-
    font-family: var(--font-family-monospace);
+

+
  .subtitle {
    font-size: var(--font-size-small);
-
  }
-
  .seeding-policy {
+
    color: var(--color-foreground-dim);
    display: flex;
    align-items: center;
    gap: 0.5rem;
+
    width: 100%;
+
    margin-top: 1rem;
+
  }
+
  .projects {
+
    margin-top: 0;
  }
-

  .project-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
    gap: 1rem;
  }
  .empty-state {
-
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    height: 35vh;
+
    font-size: var(--font-size-small);
  }
-
  .empty-state .heading {
+
  .box {
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-bold);
+
    line-height: 1.625rem;
+
    width: 17rem;
  }
-
  .empty-state .label {
-
    display: block;
+
  code {
+
    font-family: var(--font-family-monospace);
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
+
    background-color: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-tiny);
+
    padding: 0.125rem 0.25rem;
  }
-
  @media (max-width: 719.98px) {
+
  .desktop-hostname {
+
    max-width: 22rem;
+
  }
+
  @media (max-width: 1010.98px) {
    .wrapper {
-
      width: 100%;
-
      padding: 1rem;
+
      padding: 1.5rem;
+
    }
+
    .sidebar {
+
      width: 325px;
    }
-
    .info {
+
    .content {
+
      margin-left: 325px;
+
    }
+
    .desktop-hostname {
+
      max-width: 12rem;
+
    }
+
  }
+

+
  @media (max-width: 719.98px) {
+
    .title {
+
      display: flex;
      flex-direction: column;
+
      margin-left: 1.5rem;
    }
    .layout {
+
      height: 100%;
+
    }
+
    .below-header {
+
      height: 100%;
+
    }
+
    .header {
+
      display: none;
+
    }
+
    .content {
+
      overflow-x: hidden;
+
      margin-left: 0;
+
    }
+
    .wrapper {
+
      width: 100%;
+
      padding: 1rem;
+
    }
+
    .container {
      padding: 0;
    }
+
    .projects {
+
      margin-top: 3rem;
+
    }
+
    .mobile-footer {
+
      margin-top: auto;
+
      display: grid;
+
      grid-column: 1 / 4;
+
      background-color: pink;
+
    }
  }
</style>

-
<AppLayout>
-
  <div class="layout">
-
    <div class="wrapper">
-
      <div class="header">
-
        <div class="txt-large txt-bold">{baseUrl.hostname}</div>
-
        <div class="info">
+
<svelte:window bind:scrollY />
+

+
<div class="layout">
+
  <div class="header" style:background-image={background}>
+
    <div class="breadcrumbs">
+
      <Link
+
        style="display: flex; align-items: center;"
+
        route={{ resource: "nodes", params: undefined }}>
+
        <div
+
          style="background-color: var(--color-background-default);border-radius: var(--border-radius-small); display: flex; padding: 0.5rem 0;">
+
          <img
+
            style:margin="0 0.5rem"
+
            width="24"
+
            height="24"
+
            class="logo"
+
            alt="Radicle logo"
+
            src="/radicle.svg" />
+
        </div>
+
      </Link>
+
    </div>
+
  </div>
+

+
  <div class="below-header">
+
    <div
+
      class="sidebar global-hide-on-mobile-down"
+
      style:top={`${top}px`}
+
      style:height={`calc(100% - ${top}px)`}>
+
      <div class="title">
+
        <div
+
          style="display: flex; align-items: center; gap: 1rem;"
+
          style:margin-bottom="1.5rem">
+
          <img
+
            style:border-radius="var(--border-radius-small)"
+
            style:min-width="64px"
+
            width="64"
+
            height="64"
+
            class="avatar"
+
            alt="Seed avatar"
+
            src={node.avatarUrl
+
              ? node.avatarUrl
+
              : "/images/default-seed-avatar.png"} />
          <div>
-
            {#each externalAddresses as address}
-
              <!-- If there are externalAddresses this is probably a remote node -->
-
              <!-- in that case, we show all the defined externalAddresses as a listing -->
-
              <Id
-
                ariaLabel="node-id"
-
                shorten={false}
-
                id="{truncateId(nid)}@{address}"
-
                clipboard={`${nid}@${address}`} />
-
            {:else}
-
              <!-- else this is probably a local node -->
-
              <!-- So we show only the nid -->
-
              <div class="global-hide-on-small-desktop-up">
-
                <Id ariaLabel="node-id" id={truncateId(nid)} shorten={false} />
-
              </div>
-
              <div class="global-hide-on-mobile-down">
-
                <Id ariaLabel="node-id" id={nid} shorten={false} />
+
            <div class="global-flex-item desktop-hostname">
+
              <PreferredSeedDropdown>
+
                <div class="txt-medium txt-semibold txt-overflow">
+
                  {baseUrl.hostname}
+
                </div>
+
              </PreferredSeedDropdown>
+
            </div>
+
            <NodeAddress {node} />
+
          </div>
+
        </div>
+
        {#if node.description}
+
          <div class="description txt-small">
+
            {node.description}
+
          </div>
+
        {:else}
+
          <div
+
            class="global-flex-item txt-small txt-missing"
+
            style:align-items="center"
+
            style:justify-content="space-between"
+
            style:gap="0.25rem">
+
            No description configured.
+
            <Popover popoverPositionTop="0" popoverPositionLeft="2.25rem">
+
              <IconButton slot="toggle" let:toggle on:click={toggle}>
+
                <IconSmall name="info" />
+
              </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>
-
            {/each}
+
            </Popover>
+
          </div>
+
        {/if}
+
        <div
+
          style:display="flex"
+
          style:margin-top="1.5rem"
+
          style:margin-bottom="1rem"
+
          style:flex-direction="column">
+
          <PolicyExplainer seedingPolicy={node.config?.seedingPolicy} />
+
          <div class="sidebar-item">
+
            <Seeding count={stats.repos.total}>
+
              <div style:width="2rem" />
+
            </Seeding>
+
          </div>
+
          <div class="sidebar-item">
+
            <UserAgent agent={node.agent} />
          </div>
-
          <Id
-
            ariaLabel="agent"
-
            id={formatUserAgent(agent)}
-
            shorten={false}
-
            style="none">
-
            <div class="agent">
-
              {formatUserAgent(agent)}
-
            </div>
-
          </Id>
        </div>
      </div>
+
      <div class="sidebar-footer">
+
        <div
+
          style:margin-top="1.5rem"
+
          style:display="flex"
+
          style:justify-content="space-between"
+
          style:flex-direction="row">
+
          <div class="horizontal-buttons">
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Settings"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <IconSmall name="settings" />
+
                Settings
+
              </Button>

-
      <div class="subtitle" style:justify-content="space-between">
-
        <div class="txt-semibold">Pinned repositories</div>
-
        <div class="seeding-policy">
-
          {#if seedingPolicy}
-
            <span class="txt-bold">Seeding Policy:</span>
-
            {capitalize(shortScope)}
-
            <div class="global-hide-on-mobile-down">
-
              <Popover
-
                popoverPositionBottom="0"
-
                popoverPositionLeft="-17rem"
-
                popoverPositionRight="2rem">
-
                <IconButton slot="toggle" let:toggle on:click={toggle}>
-
                  <IconSmall name="help" />
-
                </IconButton>
-
                <ScopePolicyExplainer slot="popover" {seedingPolicy} />
-
              </Popover>
-
            </div>
-
          {/if}
+
              <Settings slot="popover" />
+
            </Popover>
+
          </div>
+
          <div class="horizontal-buttons">
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Help"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <IconSmall name="help" />
+
                Help
+
              </Button>
+
              <Help slot="popover" />
+
            </Popover>
+
          </div>
        </div>
      </div>
+
    </div>

-
      <div style:margin-top="1rem" style:padding-bottom="2.5rem">
-
        {#await fetchProjectInfos( baseUrl, { show: "pinned", perPage: stats.repos.total }, )}
-
          <div style:height="35vh">
-
            <Loading small center />
+
    <div class="content">
+
      <div class="global-hide-on-small-desktop-up">
+
        <div
+
          class="mobile-header txt-huge txt-semibold"
+
          style:background-image={background}>
+
        </div>
+
      </div>
+
      <div class="container">
+
        <div class="wrapper">
+
          <div
+
            class="global-hide-on-small-desktop-up"
+
            style="display: flex; align-items: center; gap: 1rem;">
+
            <img
+
              style:min-width="64px"
+
              style:border-radius="var(--border-radius-small)"
+
              width="64"
+
              height="64"
+
              alt="Seed avatar"
+
              src={node.avatarUrl
+
                ? node.avatarUrl
+
                : "/images/default-seed-avatar.png"} />
+
            <div>
+
              <div class="global-flex-item">
+
                <PreferredSeedDropdown>
+
                  <div class="txt-medium txt-semibold txt-overflow">
+
                    {baseUrl.hostname}
+
                  </div>
+
                </PreferredSeedDropdown>
+
              </div>
+
              <NodeAddress {node} />
+
            </div>
          </div>
-
        {:then projectInfos}
-
          {#if projectInfos.length > 0}
-
            <div class="project-grid">
-
              {#each projectInfos as projectInfo}
-
                <ProjectCard {projectInfo} />
-
              {/each}
+
          {#if node.description}
+
            <div
+
              class="global-hide-on-small-desktop-up"
+
              style:margin-top="1.5rem"
+
              style:display="flex"
+
              style:flex-direction="column"
+
              style:gap="0.25rem">
+
              {#if node.description}
+
                <div class="description txt-small">
+
                  {node.description}
+
                </div>
+
              {/if}
            </div>
          {:else}
-
            <div class="empty-state">
-
              <div class="heading">No pinned repositories</div>
-
              <div class="label">
-
                This node doesn't have any pinned repositories.
-
              </div>
+
            <div
+
              class="global-flex-item txt-small txt-missing global-hide-on-small-desktop-up"
+
              style:margin-top="1.5rem">
+
              No description configured.
            </div>
          {/if}
-
        {:catch error}
-
          {router.push(handleError(error, baseUrlToString(baseUrl)))}
-
        {/await}
+

+
          <div class="projects">
+
            {#await fetchProjectInfos( baseUrl, { show: "pinned", perPage: stats.repos.total }, )}
+
              <div style:height="35vh">
+
                <Loading small center />
+
              </div>
+
            {:then projectInfos}
+
              {#if projectInfos.length > 0}
+
                <div class="project-grid">
+
                  {#each projectInfos as projectInfo}
+
                    <ProjectCard {projectInfo} />
+
                  {/each}
+
                </div>
+
                <div class="subtitle">
+
                  {projectInfos.length}
+
                  pinned {projectInfos.length === 1
+
                    ? "repository"
+
                    : "repositories"}
+
                </div>
+
              {:else}
+
                <div class="empty-state">
+
                  This node doesn't have any pinned repositories.
+
                </div>
+
              {/if}
+
            {:catch error}
+
              {router.push(handleError(error, baseUrlToString(baseUrl)))}
+
            {/await}
+
          </div>
+
        </div>
      </div>
    </div>
+

+
    <div class="mobile-footer">
+
      <MobileFooter>
+
        <div style:width="100%">
+
          <Popover popoverPositionBottom="3rem" popoverPositionRight="-7.5rem">
+
            <Button
+
              let:expanded
+
              slot="toggle"
+
              variant={expanded ? "secondary" : "secondary-mobile-toggle"}
+
              styleWidth="100%"
+
              let:toggle
+
              on:click={toggle}>
+
              <IconSmall name="seedling" />
+
            </Button>
+

+
            <div slot="popover" style:width="20rem">
+
              <PolicyExplainer seedingPolicy={node.config?.seedingPolicy} />
+
              <UserAgent agent={node.agent} />
+
            </div>
+
          </Popover>
+
        </div>
+
      </MobileFooter>
+
    </div>
  </div>
-
</AppLayout>
+
</div>
modified src/views/nodes/router.ts
@@ -1,4 +1,4 @@
-
import type { BaseUrl, DefaultSeedingPolicy, NodeStats } from "@http-client";
+
import type { BaseUrl, Node, NodeStats } from "@http-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";

import config from "virtual:config";
@@ -7,11 +7,15 @@ import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/projects/error";
+
import { preferredSeeds } from "@app/lib/seeds";
+
import { get } from "svelte/store";

-
export interface NodesRouteParams {
-
  baseUrl: BaseUrl;
-
  projectPageIndex: number;
-
}
+
export type NodesRouteParams =
+
  | {
+
      baseUrl: BaseUrl;
+
      projectPageIndex: number;
+
    }
+
  | undefined;

export interface NodesRoute {
  resource: "nodes";
@@ -22,11 +26,8 @@ export interface NodesLoadedRoute {
  resource: "nodes";
  params: {
    baseUrl: BaseUrl;
-
    agent: string;
-
    externalAddresses: string[];
-
    nid: string;
    stats: NodeStats;
-
    seedingPolicy?: DefaultSeedingPolicy;
+
    node: Node;
  };
}

@@ -43,10 +44,17 @@ export function nodePath(baseUrl: BaseUrl) {
export async function loadNodeRoute(
  params: NodesRouteParams,
): Promise<NodesLoadedRoute | NotFoundRoute | ErrorRoute> {
-
  if (
-
    import.meta.env.PROD &&
-
    isLocal(`${params.baseUrl.hostname}:${params.baseUrl.port}`)
-
  ) {
+
  let baseUrl: BaseUrl;
+

+
  if (params) {
+
    baseUrl = params.baseUrl;
+
  } else {
+
    baseUrl = get(preferredSeeds).selected;
+
  }
+

+
  const api = new HttpdClient(baseUrl);
+

+
  if (import.meta.env.PROD && isLocal(`${baseUrl.hostname}:${baseUrl.port}`)) {
    return {
      resource: "error",
      params: {
@@ -56,19 +64,16 @@ export async function loadNodeRoute(
      },
    };
  }
-
  const api = new HttpdClient(params.baseUrl);
+

  try {
    const [node, stats] = await Promise.all([api.getNode(), api.getStats()]);

    return {
      resource: "nodes",
      params: {
-
        baseUrl: params.baseUrl,
-
        nid: node.id,
+
        baseUrl,
+
        node,
        stats,
-
        externalAddresses: node.config?.externalAddresses ?? [],
-
        seedingPolicy: node.config?.seedingPolicy,
-
        agent: node.agent,
      },
    };
  } catch (error) {
modified src/views/projects/Commit.svelte
@@ -1,20 +1,24 @@
<script lang="ts">
  import type { BaseUrl, Commit, Project, SeedingPolicy } from "@http-client";

+
  import { formatObjectId } from "@app/lib/utils";
+

  import Button from "@app/components/Button.svelte";
  import Changeset from "@app/views/projects/Changeset.svelte";
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Id from "@app/components/Id.svelte";
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
  import Layout from "./Layout.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Separator from "./Separator.svelte";
  import Share from "./Share.svelte";
-
  import Id from "@app/components/Id.svelte";

  export let baseUrl: BaseUrl;
  export let seedingPolicy: SeedingPolicy;
  export let commit: Commit;
  export let project: Project;
+
  export let nodeAvatarUrl: string | undefined;

  $: header = commit.commit;
</script>
@@ -45,7 +49,27 @@
  }
</style>

-
<Layout {seedingPolicy} {baseUrl} {project}>
+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project}>
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.history",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Commits
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {commit.commit.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {formatObjectId(commit.commit.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
  <div class="commit">
    <div class="header">
      <div style="display:flex; flex-direction: column; gap: 0.5rem;">
modified src/views/projects/History.svelte
@@ -4,8 +4,8 @@
    CommitHeader,
    Project,
    Remote,
-
    Tree,
    SeedingPolicy,
+
    Tree,
  } from "@http-client";
  import type { ProjectRoute } from "./router";

@@ -19,9 +19,11 @@
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Header from "./Source/Header.svelte";
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
  import List from "@app/components/List.svelte";
  import Loading from "@app/components/Loading.svelte";
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";
+
  import Separator from "./Separator.svelte";

  export let baseUrl: BaseUrl;
  export let seedingPolicy: SeedingPolicy;
@@ -32,6 +34,7 @@
  export let project: Project;
  export let revision: string | undefined;
  export let tree: Tree;
+
  export let nodeAvatarUrl: string | undefined;

  const api = new HttpdClient(baseUrl);

@@ -89,7 +92,18 @@
  }
</style>

-
<Layout {seedingPolicy} {baseUrl} {project} activeTab="source">
+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="source">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.history",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Commits
+
    </Link>
+
  </svelte:fragment>
  <ProjectNameHeader {project} {baseUrl} slot="header" />

  <div style:margin="1rem" slot="subheader">
modified src/views/projects/Issue.svelte
@@ -15,9 +15,11 @@
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
  import Labels from "@app/views/projects/Cob/Labels.svelte";
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Reactions from "@app/components/Reactions.svelte";
+
  import Separator from "./Separator.svelte";
  import Share from "@app/views/projects/Share.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";

@@ -26,6 +28,7 @@
  export let issue: Issue;
  export let project: Project;
  export let rawPath: (commit?: string) => string;
+
  export let nodeAvatarUrl: string | undefined;

  $: uniqueEmbeds = uniqBy(
    issue.discussion.flatMap(comment => comment.embeds),
@@ -110,6 +113,11 @@
    align-items: center;
    margin-left: -0.25rem;
  }
+
  .id {
+
    font-size: var(--font-size-small);
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
  @media (max-width: 719.98px) {
    .bottom {
      padding: 0;
@@ -118,11 +126,33 @@
</style>

<Layout
-
  {seedingPolicy}
  {baseUrl}
+
  {nodeAvatarUrl}
  {project}
+
  {seedingPolicy}
  activeTab="issues"
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.issues",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Issues
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {issue.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {utils.formatObjectId(issue.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
+

  <div class="issue">
    <div class="main">
      <CobHeader>
modified src/views/projects/Issues.svelte
@@ -25,6 +25,7 @@
  import Loading from "@app/components/Loading.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover from "@app/components/Popover.svelte";
+
  import Separator from "./Separator.svelte";
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
@@ -32,6 +33,7 @@
  export let issues: Issue[];
  export let project: Project;
  export let status: IssueState["status"];
+
  export let nodeAvatarUrl: string | undefined;

  let loading = false;
  let page = 0;
@@ -115,7 +117,18 @@
  }
</style>

-
<Layout {seedingPolicy} {baseUrl} {project} activeTab="issues">
+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="issues">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.issues",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Issues
+
    </Link>
+
  </svelte:fragment>
  <div slot="header" class="header">
    <Popover
      popoverPadding="0"
modified src/views/projects/Layout.svelte
@@ -2,12 +2,11 @@
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Project, SeedingPolicy } from "@http-client";

-
  import AppHeader from "@app/App/Header.svelte";
-

  import Button from "@app/components/Button.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
  import MobileFooter from "@app/App/MobileFooter.svelte";
+
  import Separator from "./Separator.svelte";
  import Sidebar from "@app/views/projects/Sidebar.svelte";

  export let activeTab: ActiveTab | undefined = undefined;
@@ -15,6 +14,7 @@
  export let baseUrl: BaseUrl;
  export let project: Project;
  export let stylePaddingBottom: string = "2.5rem";
+
  export let nodeAvatarUrl: string | undefined;
</script>

<style>
@@ -29,6 +29,21 @@
    border-bottom: 1px solid var(--color-fill-separator);
  }

+
  header {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    margin: 0;
+
    padding: 0.5rem 0.5rem 0.5rem 1rem;
+
    height: 3.5rem;
+
    justify-content: space-between;
+
  }
+

+
  .logo {
+
    height: var(--button-regular-height);
+
    margin: 0 0.5rem;
+
  }
+

  .sidebar {
    grid-column: 1 / 2;
    border-right: 1px solid var(--color-fill-separator);
@@ -43,6 +58,29 @@
    display: none;
  }

+
  .breadcrumbs {
+
    display: flex;
+
    align-items: center;
+
    column-gap: 0.25rem;
+
    line-height: 1rem;
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    white-space: nowrap;
+
    flex-wrap: wrap;
+
  }
+
  .breadcrumb {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .breadcrumb :global(a:hover) {
+
    color: var(--color-fill-secondary);
+
  }
+
  .avatar {
+
    border-radius: var(--border-radius-tiny);
+
    margin-right: 0.5rem;
+
  }
+

  @media (max-width: 719.98px) {
    .desktop-header {
      display: none;
@@ -62,7 +100,60 @@

<div class="layout">
  <div class="desktop-header">
-
    <AppHeader />
+
    <header>
+
      <div class="breadcrumbs">
+
        <span class="breadcrumb">
+
          <Link
+
            style="display: flex; align-items: center; gap: 0.25rem;"
+
            route={{
+
              resource: "nodes",
+
              params: {
+
                baseUrl,
+
                projectPageIndex: 0,
+
              },
+
            }}>
+
            <img
+
              width="24"
+
              height="24"
+
              class="avatar"
+
              alt="Radicle logo"
+
              src={nodeAvatarUrl
+
                ? nodeAvatarUrl
+
                : "/images/default-seed-avatar.png"} />
+
            {baseUrl.hostname}
+
          </Link>
+
        </span>
+

+
        <Separator />
+

+
        <span class="breadcrumb">
+
          <Link
+
            route={{
+
              resource: "project.source",
+
              project: project.id,
+
              node: baseUrl,
+
            }}>
+
            <div class="breadcrumb">
+
              {project.name}
+
            </div>
+
          </Link>
+
        </span>
+

+
        <div class="breadcrumb">
+
          <slot name="breadcrumb" />
+
        </div>
+
      </div>
+
      <Link
+
        style="display: flex; align-items: center;"
+
        route={{ resource: "nodes", params: undefined }}>
+
        <img
+
          width="24"
+
          height="24"
+
          class="logo"
+
          alt="Radicle logo"
+
          src="/radicle.svg" />
+
      </Link>
+
    </header>
  </div>

  <div class="sidebar global-hide-on-medium-desktop-down">
modified src/views/projects/Patch.svelte
@@ -71,6 +71,7 @@
  import Reviews from "@app/views/projects/Cob/Reviews.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import RevisionSelector from "@app/views/projects/Patch/RevisionSelector.svelte";
+
  import Separator from "./Separator.svelte";
  import Share from "@app/views/projects/Share.svelte";

  export let baseUrl: BaseUrl;
@@ -80,6 +81,7 @@
  export let rawPath: (commit?: string) => string;
  export let project: Project;
  export let view: PatchView;
+
  export let nodeAvatarUrl: string | undefined;

  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
    if (status === "draft") {
@@ -278,6 +280,11 @@
    gap: 0.5rem;
    width: 100%;
  }
+
  .id {
+
    font-size: var(--font-size-small);
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
  @media (max-width: 719.98px) {
    .patch {
      display: block;
@@ -289,11 +296,32 @@
</style>

<Layout
-
  {seedingPolicy}
  {baseUrl}
  {project}
+
  {nodeAvatarUrl}
+
  {seedingPolicy}
  activeTab="patches"
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.patches",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Patches
+
    </Link>
+
    <Separator />
+
    <span class="id">
+
      <div class="global-hide-on-small-desktop-down">
+
        {patch.id}
+
      </div>
+
      <div class="global-hide-on-medium-desktop-up">
+
        {utils.formatObjectId(patch.id)}
+
      </div>
+
    </span>
+
  </svelte:fragment>
  <div class="patch">
    <div class="main">
      <CobHeader>
modified src/views/projects/Patches.svelte
@@ -25,6 +25,7 @@
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
+
  import Separator from "./Separator.svelte";
  import Share from "./Share.svelte";

  export let baseUrl: BaseUrl;
@@ -32,6 +33,7 @@
  export let patches: Patch[];
  export let project: Project;
  export let status: PatchState["status"];
+
  export let nodeAvatarUrl: string | undefined;

  let loading = false;
  let page = 0;
@@ -123,7 +125,18 @@
  }
</style>

-
<Layout {seedingPolicy} {baseUrl} {project} activeTab="patches">
+
<Layout {nodeAvatarUrl} {seedingPolicy} {baseUrl} {project} activeTab="patches">
+
  <svelte:fragment slot="breadcrumb">
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.patches",
+
        project: project.id,
+
        node: baseUrl,
+
      }}>
+
      Patches
+
    </Link>
+
  </svelte:fragment>
  <div slot="header" class="header">
    <Popover
      popoverPadding="0"
added src/views/projects/Separator.svelte
@@ -0,0 +1,7 @@
+
<script lang="ts">
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
</script>
+

+
<span style:color="var(--color-foreground-dim)">
+
  <IconSmall name="chevron-right" />
+
</span>
modified src/views/projects/Sidebar.svelte
@@ -139,13 +139,6 @@
    opacity: 1;
    transition: opacity 150ms ease-in-out;
  }
-
  .collapse-label {
-
    display: none;
-
  }
-
  .collapse-label.expanded {
-
    display: block;
-
    transition: opacity 30ms ease-in-out;
-
  }
  .icon {
    transform: rotate(180deg);
    transition: transform 150ms ease-in-out;
@@ -301,36 +294,37 @@
          <div class="icon" class:expanded>
            <IconSmall name="chevron-left" />
          </div>
-
          <span class="collapse-label" class:expanded>Collapse</span>
        </Button>
-
        <div class="horizontal-buttons" class:expanded>
-
          <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
-
            <Button
-
              variant="background"
-
              title="Settings"
-
              slot="toggle"
-
              let:toggle
-
              on:click={toggle}>
-
              <IconSmall name="settings" />
-
              Settings
-
            </Button>
+
        <div class="global-flex-item">
+
          <div class="horizontal-buttons" class:expanded>
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Settings"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <IconSmall name="settings" />
+
                Settings
+
              </Button>

-
            <Settings slot="popover" />
-
          </Popover>
-
        </div>
-
        <div class="horizontal-buttons" class:expanded>
-
          <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
-
            <Button
-
              variant="background"
-
              title="Help"
-
              slot="toggle"
-
              let:toggle
-
              on:click={toggle}>
-
              <IconSmall name="help" />
-
              Help
-
            </Button>
-
            <Help slot="popover" />
-
          </Popover>
+
              <Settings slot="popover" />
+
            </Popover>
+
          </div>
+
          <div class="horizontal-buttons" class:expanded>
+
            <Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
+
              <Button
+
                variant="outline"
+
                title="Help"
+
                slot="toggle"
+
                let:toggle
+
                on:click={toggle}>
+
                <IconSmall name="help" />
+
                Help
+
              </Button>
+
              <Help slot="popover" />
+
            </Popover>
+
          </div>
        </div>
      </div>
    {/if}
modified src/views/projects/Source.svelte
@@ -16,8 +16,10 @@
  import Placeholder from "@app/components/Placeholder.svelte";

  import BlobComponent from "./Source/Blob.svelte";
-
  import TreeComponent from "./Source/Tree.svelte";
+
  import FilePath from "@app/components/FilePath.svelte";
  import ProjectNameHeader from "./Source/ProjectNameHeader.svelte";
+
  import Separator from "./Separator.svelte";
+
  import TreeComponent from "./Source/Tree.svelte";

  export let baseUrl: BaseUrl;
  export let blobResult: BlobResult;
@@ -26,10 +28,11 @@
  export let peer: string | undefined;
  export let peers: Remote[];
  export let project: Project;
-
  export let seedingPolicy: SeedingPolicy;
  export let rawPath: (commit?: string) => string;
  export let revision: string | undefined;
+
  export let seedingPolicy: SeedingPolicy;
  export let tree: Tree;
+
  export let nodeAvatarUrl: string | undefined;

  let mobileFileTree = false;

@@ -117,11 +120,18 @@
</style>

<Layout
-
  {seedingPolicy}
  {baseUrl}
+
  {nodeAvatarUrl}
  {project}
+
  {seedingPolicy}
  activeTab="source"
  stylePaddingBottom="0">
+
  <svelte:fragment slot="breadcrumb">
+
    {#if path !== "/"}
+
      <Separator />
+
      <FilePath filenameWithPath={path} />
+
    {/if}
+
  </svelte:fragment>
  <ProjectNameHeader {project} {baseUrl} slot="header" />

  <div style:margin="1rem" slot="subheader">
modified src/views/projects/router.ts
@@ -120,6 +120,7 @@ export type ProjectLoadedRoute =
        path: string;
        rawPath: (commit?: string) => string;
        blobResult: BlobResult;
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -134,6 +135,7 @@ export type ProjectLoadedRoute =
        revision: string | undefined;
        tree: Tree;
        commitHeaders: CommitHeader[];
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -143,6 +145,7 @@ export type ProjectLoadedRoute =
        seedingPolicy: SeedingPolicy;
        project: Project;
        commit: Commit;
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -153,6 +156,7 @@ export type ProjectLoadedRoute =
        project: Project;
        rawPath: (commit?: string) => string;
        issue: Issue;
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -163,6 +167,7 @@ export type ProjectLoadedRoute =
        project: Project;
        issues: Issue[];
        status: IssueState["status"];
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -173,6 +178,7 @@ export type ProjectLoadedRoute =
        project: Project;
        patches: Patch[];
        status: PatchState["status"];
+
        nodeAvatarUrl: string | undefined;
      };
    }
  | {
@@ -185,6 +191,7 @@ export type ProjectLoadedRoute =
        patch: Patch;
        stats: Diff["stats"];
        view: PatchView;
+
        nodeAvatarUrl: string | undefined;
      };
    };

@@ -264,10 +271,11 @@ export async function loadProjectRoute(
    } else if (route.resource === "project.history") {
      return await loadHistoryView(route);
    } else if (route.resource === "project.commit") {
-
      const [project, commit, seedingPolicy] = await Promise.all([
+
      const [project, commit, seedingPolicy, node] = await Promise.all([
        api.project.getById(route.project),
        api.project.getCommitBySha(route.project, route.commit),
        api.getPoliciesById(route.project),
+
        api.getNode(),
      ]);

      return {
@@ -277,6 +285,7 @@ export async function loadProjectRoute(
          seedingPolicy,
          project,
          commit,
+
          nodeAvatarUrl: node.avatarUrl,
        },
      };
    } else if (route.resource === "project.issue") {
@@ -310,7 +319,7 @@ async function loadPatchesView(
  const searchParams = new URLSearchParams(route.search || "");
  const status = (searchParams.get("status") as PatchState["status"]) || "open";

-
  const [project, patches, seedingPolicy] = await Promise.all([
+
  const [project, patches, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllPatches(route.project, {
      status,
@@ -318,6 +327,7 @@ async function loadPatchesView(
      perPage: PATCHES_PER_PAGE,
    }),
    api.getPoliciesById(route.project),
+
    api.getNode(),
  ]);

  return {
@@ -328,6 +338,7 @@ async function loadPatchesView(
      patches,
      status,
      project,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
@@ -338,7 +349,7 @@ async function loadIssuesView(
  const api = new HttpdClient(route.node);
  const status = route.status || "open";

-
  const [project, issues, seedingPolicy] = await Promise.all([
+
  const [project, issues, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllIssues(route.project, {
      status,
@@ -346,6 +357,7 @@ async function loadIssuesView(
      perPage: ISSUES_PER_PAGE,
    }),
    api.getPoliciesById(route.project),
+
    api.getNode(),
  ]);

  return {
@@ -356,6 +368,7 @@ async function loadIssuesView(
      issues,
      status,
      project,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
@@ -387,10 +400,11 @@ async function loadTreeView(
    seedingPolicyPromise = api.getPoliciesById(route.project);
  }

-
  const [project, peers, seedingPolicy] = await Promise.all([
+
  const [project, peers, seedingPolicy, node] = await Promise.all([
    projectPromise,
    peersPromise,
    seedingPolicyPromise,
+
    api.getNode(),
  ]);

  let branchMap: Record<string, string> = {
@@ -438,6 +452,7 @@ async function loadTreeView(
      tree,
      path,
      blobResult,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
@@ -496,11 +511,12 @@ async function loadHistoryView(
): Promise<ProjectLoadedRoute> {
  const api = new HttpdClient(route.node);

-
  const [project, peers, seedingPolicy, branchMap] = await Promise.all([
+
  const [project, peers, seedingPolicy, branchMap, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getAllRemotes(route.project),
    api.getPoliciesById(route.project),
    getPeerBranches(api, route.project, route.peer),
+
    api.getNode(),
  ]);

  let commitId;
@@ -539,6 +555,7 @@ async function loadHistoryView(
      revision: route.revision,
      tree,
      commitHeaders,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
@@ -552,10 +569,11 @@ async function loadIssueView(
      route.project
    }${commit ? `/${commit}` : ""}`;

-
  const [project, issue, seedingPolicy] = await Promise.all([
+
  const [project, issue, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getIssueById(route.project, route.issue),
    api.getPoliciesById(route.project),
+
    api.getNode(),
  ]);
  return {
    resource: "project.issue",
@@ -565,6 +583,7 @@ async function loadIssueView(
      project,
      rawPath,
      issue,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
@@ -578,10 +597,11 @@ async function loadPatchView(
      route.project
    }${commit ? `/${commit}` : ""}`;

-
  const [project, patch, seedingPolicy] = await Promise.all([
+
  const [project, patch, seedingPolicy, node] = await Promise.all([
    api.project.getById(route.project),
    api.project.getPatchById(route.project, route.patch),
    api.getPoliciesById(route.project),
+
    api.getNode(),
  ]);
  const latestRevision = patch.revisions[patch.revisions.length - 1];
  const { diff } = await api.project.getDiff(
@@ -643,6 +663,7 @@ async function loadPatchView(
      patch,
      stats: diff.stats,
      view,
+
      nodeAvatarUrl: node.avatarUrl,
    },
  };
}
modified tests/build/smoke.spec.ts
@@ -1,9 +1,11 @@
import { test, expect } from "@tests/support/fixtures.js";

test("exceptions in production build", async ({ page }) => {
-
  await page.goto("/");
+
  await page.goto("/nodes/127.0.0.1:8081");
  // 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("Explore", { exact: true })).toBeVisible();
+
  await expect(
+
    page.getByText("Local node browsing not supported"),
+
  ).toBeVisible();
});
modified tests/e2e/clipboard.spec.ts
@@ -4,7 +4,6 @@ import {
  expect,
  sourceBrowsingUrl,
  sourceBrowsingRid,
-
  nodeRemote,
  test,
} from "@tests/support/fixtures.js";

@@ -60,13 +59,6 @@ test("copy to clipboard", async ({ page, browserName, context }) => {
    );
  }

-
  await page.goto("/nodes/radicle.local");
-
  // Node address.
-
  {
-
    await page.getByRole("button", { name: "node-id" }).first().click();
-
    await expectClipboard(`${nodeRemote}`, page);
-
  }
-

  // Clear the system clipboard contents so developers don't wonder why there's
  // random stuff in their clipboard after running tests.
  await page.evaluate<string>("navigator.clipboard.writeText('')");
deleted tests/e2e/landingPage.spec.ts
@@ -1,143 +0,0 @@
-
import { expect, test } from "@tests/support/fixtures.js";
-

-
test("show pinned repositories", async ({ context, page }) => {
-
  await context.addInitScript(() => {
-
    localStorage.setItem(
-
      "configuredPreferredSeeds",
-
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
-
    );
-
  });
-

-
  await page.goto("/");
-
  // Shows pinned project name.
-
  await expect(page.getByText("source-browsing")).toBeVisible();
-
  //
-
  // Shows pinned project description.
-
  await expect(
-
    page.getByText("Git repository for source browsing tests"),
-
  ).toBeVisible();
-
});
-

-
test("no duplicate entry for preferred seeds", async ({ page }) => {
-
  await page.goto("/");
-
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.radicle.garden" }),
-
  ).toBeVisible();
-

-
  await page
-
    .getByPlaceholder("Navigate to seed URL")
-
    .fill("seed.radicle.garden");
-
  await page.getByPlaceholder("Navigate to seed URL").press("Enter");
-
  await expect(page.getByText("Seed node already added.")).toBeVisible();
-

-
  await page.getByPlaceholder("Navigate to seed URL").fill("");
-
  await expect(page.getByText("Seed node already added.")).toBeHidden();
-
});
-

-
test("adding and removing a new preferred seed", async ({ page }) => {
-
  await page.route(
-
    ({ hostname }) => hostname === "seed.rhizoma.dev",
-
    route => route.fulfill({ json: nodeInfo }),
-
  );
-

-
  await page.goto("/");
-
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.radicle.garden" }),
-
  ).toBeVisible();
-

-
  await page.getByPlaceholder("Navigate to seed URL").fill("seed.rhizoma.dev");
-
  await page.getByPlaceholder("Navigate to seed URL").press("Enter");
-
  await expect(page.getByText("seed.rhizoma.dev")).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
-
  ).toBeVisible();
-

-
  // Test that removing the selected seed doesn't end in an undefined state.
-
  await page
-
    .getByRole("button", { name: "seed.rhizoma.dev" })
-
    .getByRole("button")
-
    .click();
-
  await expect(page.getByText("seed.radicle.garden")).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
-
  ).toBeHidden();
-
});
-

-
test("stored custom preferred seeds in local storage", async ({ page }) => {
-
  await page.addInitScript(() =>
-
    localStorage.setItem(
-
      "configuredPreferredSeeds",
-
      '[{"hostname":"seed.radicle.xyz","port":443,"scheme":"https"},{"hostname":"seed.rhizoma.dev","port":443,"scheme":"https"}]',
-
    ),
-
  );
-
  await page.goto("/");
-
  await expect(page.getByText("seed.radicle.xyz")).toBeVisible();
-

-
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
-
  await expect(
-
    page.getByRole("button", { name: "seed.radicle.xyz" }),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByRole("button", { name: "seed.rhizoma.dev" }),
-
  ).toBeVisible();
-
  // Check that the fallback node hasn't been added on load.
-
  await expect(
-
    page.getByRole("button", { name: "seed.radicle.garden" }),
-
  ).toBeHidden();
-
});
-

-
const nodeInfo = {
-
  id: "z6MkkGfMNQmjrp66Po2n4snzcSyTFRFw1m1fbYhCURxLxZpD",
-
  agent: "/radicle:1.0.0-rc.11/",
-
  config: {
-
    alias: "rhizoma",
-
    listen: [],
-
    peers: {
-
      type: "dynamic",
-
    },
-
    connect: [],
-
    externalAddresses: ["seed.rhizoma.dev:8776"],
-
    db: {
-
      journalMode: "wal",
-
    },
-
    network: "main",
-
    log: "INFO",
-
    relay: "auto",
-
    limits: {
-
      routingMaxSize: 1000,
-
      routingMaxAge: 604800,
-
      gossipMaxAge: 1209600,
-
      fetchConcurrency: 1,
-
      maxOpenFiles: 4096,
-
      rate: {
-
        inbound: {
-
          fillRate: 2,
-
          capacity: 128,
-
        },
-
        outbound: {
-
          fillRate: 5,
-
          capacity: 256,
-
        },
-
      },
-
      connection: {
-
        inbound: 128,
-
        outbound: 16,
-
      },
-
    },
-
    workers: 32,
-
    seedingPolicy: {
-
      default: "block",
-
    },
-
  },
-
  state: "running",
-
};
modified tests/e2e/node.spec.ts
@@ -21,11 +21,8 @@ test("node metadata", async ({ page, peerManager }) => {

  await page.goto(peer.uiUrl());

-
  await expect(page.getByRole("link", { name: "127.0.0.1" })).toBeVisible();
-
  await expect(
-
    page.getByText(`${shortNodeRemote}@seed.radicle.test:8123`),
-
  ).toBeVisible();
-
  await expect(page.getByText("radicle:1.0.0-rc.11")).toBeVisible();
+
  await expect(page.getByText(shortNodeRemote).first()).toBeVisible();
+
  await expect(page.getByText("/radicle:1.0.0-rc.11/")).toBeVisible();
});

test("node projects", async ({ page }) => {
@@ -42,3 +39,73 @@ test("node projects", async ({ page }) => {
    ).toBeVisible();
  }
});
+

+
test("show pinned repositories", async ({ page }) => {
+
  await page.goto("/");
+
  // Shows pinned project name.
+
  await expect(page.getByText("source-browsing")).toBeVisible();
+
  //
+
  // Shows pinned project description.
+
  await expect(
+
    page.getByText("Git repository for source browsing tests"),
+
  ).toBeVisible();
+
});
+

+
test("no duplicate entry for preferred seeds", async ({ page }) => {
+
  await page.goto("/");
+
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(page.getByRole("button", { name: "127.0.0.1" })).toBeVisible();
+

+
  await page.getByPlaceholder("Navigate to seed").fill("127.0.0.1");
+
  await page.getByPlaceholder("Navigate to seed").press("Enter");
+
  await expect(page.getByText("Seed node already added.")).toBeVisible();
+

+
  await page.getByPlaceholder("Navigate to seed").fill("");
+
  await expect(page.getByText("Seed node already added.")).toBeHidden();
+
});
+

+
test("adding and removing a new preferred seed", async ({ page }) => {
+
  // Proxy requests to seed.example.tld to the local test api.
+
  await page.route(
+
    url => url.hostname === "seed.example.tld",
+
    route =>
+
      route.fulfill({
+
        status: 301,
+
        headers: {
+
          Location: route
+
            .request()
+
            .url()
+
            .replace("seed.example.tld", "127.0.0.1"),
+
        },
+
      }),
+
  );
+

+
  await page.goto("/");
+
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(page.getByRole("button", { name: "127.0.0.1" })).toBeVisible();
+

+
  await page.getByPlaceholder("Navigate to seed").fill("seed.example.tld");
+
  await page.getByPlaceholder("Navigate to seed").press("Enter");
+
  await expect(page.getByText("seed.example.tld").first()).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.example.tld" }),
+
  ).toBeVisible();
+

+
  // Test that removing the selected seed doesn't end in an undefined state.
+
  await page
+
    .getByRole("button", { name: "seed.example.tld" })
+
    .getByRole("button")
+
    .click();
+
  await expect(page.getByText("127.0.0.1").first()).toBeVisible();
+

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await expect(
+
    page.getByRole("button", { name: "seed.example.tld" }),
+
  ).toBeHidden();
+
});
modified tests/unit/router.test.ts
@@ -13,9 +13,6 @@ describe("route invariant when parsed", () => {
    scheme: "http",
  };

-
  test("home", () => {
-
    return expectParsingInvariant({ resource: "home" });
-
  });
  test("nodes", () => {
    expectParsingInvariant({
      resource: "nodes",
@@ -213,11 +210,6 @@ describe("pathToRoute", () => {
    expectPathToRoute("/foo/baz/bar", null);
  });

-
  test("home", () => {
-
    expectPathToRoute("", { resource: "home" });
-
    expectPathToRoute("/", { resource: "home" });
-
  });
-

  test("nodes", () => {
    expectPathToRoute("/nodes/example.node.tld", {
      resource: "nodes",
modified tests/visual/desktop/node.spec.ts
@@ -17,7 +17,7 @@ test("node page", async ({ page }) => {
test("empty pinned projects", async ({ page }) => {
  await page.route(
    ({ hostname, pathname }) =>
-
      pathname === "/api/v1/projects" && hostname === "seed.radicle.garden",
+
      pathname === "/api/v1/projects" && hostname === "127.0.0.1",
    async route => {
      await route.fulfill({
        status: 200,