Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
async homepage sections
Merged did:key:z6MkwdzD...LXno opened 2 years ago

check check-visual check-unit-test check-httpd-api-unit-test check-e2e check-build

πŸ‘‰ Preview
πŸ‘‰ Workflow runs
πŸ‘‰ Branch on GitHub

7 files changed +112 -99 38f0a0ca β†’ beb74abe
modified src/App/Header.svelte
@@ -76,7 +76,7 @@
        </div>
      </Popover>
    {:else}
-
      <NodeInfo running={$httpdStore.node === "running"} />
+
      <NodeInfo running={$httpdStore.node.state === "running"} />
      <Authenticate />
    {/if}
  </div>
added src/lib/deduplicateStore.ts
@@ -0,0 +1,18 @@
+
import type { Readable, Writable } from "svelte/store";
+

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

+
// Returns a derived store that only notifies subscribers if the value has changed.
+
export function deduplicateStore<T>(
+
  store: Readable<T> | Writable<T>,
+
): Readable<T> {
+
  let previous: T;
+

+
  return derived(store, ($value, set) => {
+
    if (!isEqual($value, previous)) {
+
      previous = $value;
+
      set($value);
+
    }
+
  });
+
}
modified src/lib/httpd.ts
@@ -1,8 +1,11 @@
-
import { derived, get, writable } from "svelte/store";
+
import type { Node } from "@httpd-client";
+

+
import { get, writable } from "svelte/store";
import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";

import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
+
import { deduplicateStore } from "@app/lib/deduplicateStore";

export interface Session {
  id: string;
@@ -12,18 +15,18 @@ export interface Session {

export type HttpdState =
  | { state: "stopped" }
-
  | { state: "running"; node: "running" | "stopped" }
+
  | { state: "running"; node: Node }
  | {
      state: "authenticated";
      session: Session;
-
      node: "running" | "stopped";
+
      node: Node;
    };

const HTTPD_STATE_STORAGE_KEY = "httpdState";
const HTTPD_CUSTOM_PORT_KEY = "httpdCustomPort";

const store = writable<HttpdState>({ state: "stopped" });
-
export const httpdStore = derived(store, s => s);
+
export const httpdStore = deduplicateStore(store);

export const api = new HttpdClient({
  hostname: "127.0.0.1",
@@ -57,7 +60,7 @@ export async function authenticate(params: {
        sig: params.signature,
        pk: params.publicKey,
      });
-
      const { state: node } = await api.getNode();
+
      const node = await api.getNode();
      const sess = await api.session.getById(params.id);
      update({
        state: "authenticated",
@@ -88,7 +91,7 @@ export async function disconnect() {

      try {
        await api.session.delete(httpd.session.id);
-
        const { state: node } = await api.getNode();
+
        const node = await api.getNode();
        update({ state: "running", node });
      } catch (error) {
        console.error(error);
@@ -120,7 +123,7 @@ async function checkState() {
  await stateMutex
    .runExclusive(async () => {
      try {
-
        const { state: node } = await api.getNode();
+
        const node = await api.getNode();

        if (httpdState && httpdState.state !== "stopped") {
          httpdState.node = node;
modified src/views/home/Index.svelte
@@ -1,12 +1,18 @@
<script lang="ts">
-
  import type { ProjectWithListingData } from "@app/lib/projects";
-
  import type { BaseUrl } from "@httpd-client";
+
  import {
+
    getProjectsListingData,
+
    type ProjectWithListingData,
+
  } from "@app/lib/projects";
+
  import { HttpdClient, type BaseUrl } from "@httpd-client";

+
  import { derived } from "svelte/store";
  import { z } from "zod";
  import storedWritable from "@efstajas/svelte-stored-writable";

-
  import { httpdStore } from "@app/lib/httpd";
+
  import { api, httpdStore } from "@app/lib/httpd";
  import { isDelegate } from "@app/lib/roles";
+
  import { deduplicateStore } from "@app/lib/deduplicateStore";
+
  import { prefferedSeeds } from "@app/lib/seeds";

  import AppLayout from "@app/App/AppLayout.svelte";
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
@@ -18,10 +24,48 @@
  import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
  import HomepageSection from "./components/HomepageSection.svelte";

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

+
  $: nodeId = $httpdStore.state !== "stopped" ? $httpdStore.node.id : undefined;
+

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

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

+
    return await getProjectsListingData(projects);
+
  }
+

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

+
  async function loadLocalProjects() {
+
    localProjects = undefined;
+
    localProjects = await fetchProjects(api.baseUrl, "all").catch(
+
      handleProjectLoadError,
+
    );
+
  }
+
  $: nodeId && void loadLocalProjects();
+
  const selectedSeed = deduplicateStore(
+
    derived(prefferedSeeds, $ => $?.selected),
+
  );
+

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

+
    if (!$selectedSeed) return;
+
    preferredSeedProjects = await fetchProjects($selectedSeed, "pinned").catch(
+
      handleProjectLoadError,
+
    );
+
  }
+
  $: $selectedSeed && void loadPreferredSeedProjects();

  const localProjectsFilterSchema = z.union([
    z.literal("all"),
@@ -35,28 +79,16 @@
  );

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

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

-
  let prevHttpdState = $httpdStore.state;
-

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

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

-
    prevHttpdState = newState;
+
    return localProjects?.some(p => p.project.id === projectId) ?? false;
  }
-

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

<style>
@@ -110,9 +142,10 @@
  <div class="wrapper">
    <div class="global-hide-on-mobile">
      <HomepageSection
+
        loading={$httpdStore.state !== "stopped" && localProjects === undefined}
        empty={localProjects === "error" ||
-
          !nodeId ||
-
          !filteredLocalProjects.length}
+
          $httpdStore.state === "stopped" ||
+
          !filteredLocalProjects?.length}
        title="Local projects"
        subtitle="Projects you’re seeding with your local node">
        <svelte:fragment slot="actions">
@@ -131,7 +164,7 @@
                There was an error loading projects from your local node.
              </div>
              <div class="action"><Button>Learn more</Button></div>
-
            {:else if !localProjects.length}
+
            {:else if !localProjects?.length}
              <div class="heading">No local projects</div>
              <div class="label">
                Seed or check out a project to work with it on your local node.
@@ -145,7 +178,7 @@
          </div>
        </svelte:fragment>
        <div class="project-grid">
-
          {#if filteredLocalProjects !== "error"}
+
          {#if filteredLocalProjects && filteredLocalProjects !== "error"}
            {#each filteredLocalProjects as { project, baseUrl, activity, lastCommit }}
              <ProjectCard
                id={project.id}
@@ -166,13 +199,18 @@
    </div>

    <HomepageSection
+
      loading={preferredSeedProjects === undefined}
      empty={preferredSeedProjects === "error" ||
-
        preferredSeedProjects.length === 0}
+
        preferredSeedProjects?.length === 0}
      title="Explore"
      subtitle="Pinned projects on your selected seed node">
      <svelte:fragment slot="actions">
        <div class="seed-dropdown">
-
          <PreferredSeedDropdown disabled={!nodeId} {preferredSeed} />
+
          {#if $prefferedSeeds}
+
            <PreferredSeedDropdown
+
              disabled={!nodeId || preferredSeedProjects === undefined}
+
              preferredSeed={$prefferedSeeds?.selected} />
+
          {/if}
        </div>
      </svelte:fragment>
      <svelte:fragment slot="empty">
@@ -191,7 +229,7 @@
        </div>
      </svelte:fragment>
      <div class="project-grid">
-
        {#if preferredSeedProjects !== "error"}
+
        {#if preferredSeedProjects && preferredSeedProjects !== "error"}
          {#each preferredSeedProjects as { project, baseUrl, activity, lastCommit }}
            <ProjectCard
              id={project.id}
modified src/views/home/components/HomepageSection.svelte
@@ -1,8 +1,10 @@
<script lang="ts">
+
  import Loading from "@app/components/Loading.svelte";
  import TransitionedHeight from "@app/components/TransitionedHeight.svelte";

  export let title: string;
  export let subtitle: string;
+
  export let loading = false;

  export let empty: boolean = false;
</script>
@@ -45,7 +47,7 @@

  .empty-container > .inner {
    max-width: 36rem;
-
    min-height: 12rem;
+
    min-height: 14rem;
    display: flex;
    flex-direction: column;
    justify-content: center;
@@ -64,7 +66,13 @@
  </div>

  <TransitionedHeight transitionHeightChanges>
-
    {#if empty}
+
    {#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" />
modified src/views/home/components/PreferredSeedDropdown.svelte
@@ -5,6 +5,7 @@
    prefferedSeeds as preferredSeedsStore,
    selectPreferredSeed,
  } from "@app/lib/seeds";
+
  import { closeFocused } from "@app/components/Popover.svelte";

  import Popover from "@app/components/Popover.svelte";
  import Button from "@app/components/Button.svelte";
@@ -76,7 +77,7 @@
            let:item
            on:click={() => {
              selectPreferredSeed(item);
-
              window.location.reload();
+
              closeFocused();
            }}
            slot="item"
            selected={item.hostname === preferredSeed.hostname}>
modified src/views/home/router.ts
@@ -1,77 +1,22 @@
-
import type { BaseUrl } from "@httpd-client";
-
import type { ProjectWithListingData } from "@app/lib/projects";
-

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

import type { LoadErrorRoute } from "@app/lib/router/definitions";
-
import { getProjectsListingData } from "@app/lib/projects";
import * as seeds from "@app/lib/seeds";
-
import { api, httpdStore } from "@app/lib/httpd";
-
import { HttpdClient } from "@httpd-client";
-

export interface HomeRoute {
  resource: "home";
}

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

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

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

-
function handleProjectLoadError(error: unknown): "error" {
-
  console.error(error);
-
  return "error";
+
  params: Record<string, never>;
}

export async function loadHomeRoute(): Promise<
  HomeLoadedRoute | LoadErrorRoute
> {
  seeds.initialize();
-
  const preferredSeeds = await seeds.waitForLoad();
-

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

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

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

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

  return {
    resource: "home",
-
    params: {
-
      localProjects: projectsWithListingData[0],
-
      preferredSeedProjects: projectsWithListingData[1],
-
      preferredSeed: preferredSeeds.selected,
-
      nodeId,
-
    },
+
    params: {},
  };
}