Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Improve search
Sebastian Martinez committed 3 years ago
commit 138603ffd993e675b9b2d9083f282aa3276bff03
parent 47e90aae70fa1cf223625375fc2dc6feabf7a093
10 files changed +264 -170
modified src/App.svelte
@@ -10,7 +10,6 @@
  import Faucet from "@app/base/faucet/Routes.svelte";
  import Projects from "@app/base/projects/Routes.svelte";
  import Profile from "@app/Profile.svelte";
-
  import Resolver from "@app/base/resolver/Routes.svelte";
  import Header from "@app/Header.svelte";
  import Loading from "@app/Loading.svelte";
  import Modal from "@app/Modal.svelte";
@@ -94,7 +93,6 @@
        <Registrations {config} session={$session} />
        <Seeds {config} session={$session} />
        <Faucet {config} />
-
        <Resolver {config} />
        <Route path="/:addressOrName" let:params>
          <Profile addressOrName={params.addressOrName} {config} />
        </Route>
modified src/Header.svelte
@@ -15,18 +15,27 @@
  import Icon from "./Icon.svelte";
  import MobileNavbar from "./MobileNavbar.svelte";
  import SeedDropdown from "./SeedDropdown.svelte";
-
  import Button from "@app/Button.svelte";
  import ThemeToggle from "./ThemeToggle.svelte";
+
  import Button from "@app/Button.svelte";
+
  import SearchResults from "@app/components/Modal/SearchResults.svelte";
+
  import type { ResolvedSearch } from "@app/resolver";

  export let session: Session | null;
  export let config: Config;

+
  let query: string;
+
  let results: ResolvedSearch;
+

  let sessionButtonHover = false;
  let mobileNavbarDisplayed = false;
+
  let searchResultsDisplayed = false;

  function toggleNavbar() {
    mobileNavbarDisplayed = !mobileNavbarDisplayed;
  }
+
  function toggleSearchResults() {
+
    searchResultsDisplayed = !searchResultsDisplayed;
+
  }

  $: address = session && session.address;
  $: tokenBalance = session && session.tokenBalance;
@@ -162,7 +171,12 @@
  <div class="left">
    <a use:link href="/" class="logo"><Logo /></a>
    <div class="search">
-
      <Search />
+
      <Search
+
        {config}
+
        on:search={e => {
+
          ({ query, results } = e.detail);
+
          toggleSearchResults();
+
        }} />
    </div>
    <div class="nav">
      {#if session && Object.keys(session.siwe).length > 0}
@@ -233,6 +247,15 @@
  </div>

  {#if mobileNavbarDisplayed}
-
    <MobileNavbar on:select={toggleNavbar} />
+
    <MobileNavbar
+
      {config}
+
      on:search={e => {
+
        ({ query, results } = e.detail);
+
        toggleSearchResults();
+
        toggleNavbar();
+
      }} />
+
  {/if}
+
  {#if searchResultsDisplayed}
+
    <SearchResults {config} {results} {query} on:close={toggleSearchResults} />
  {/if}
</header>
modified src/MobileNavbar.svelte
@@ -3,9 +3,12 @@
  import { createEventDispatcher } from "svelte";
  import Search from "./Search.svelte";
  import { clickOutside } from "@app/utils";
+
  import type { Config } from "@app/config";

  const dispatch = createEventDispatcher();

+
  export let config: Config;
+

  function handleClickOutside() {
    dispatch("select");
  }
@@ -63,7 +66,7 @@
  <div use:clickOutside={handleClickOutside} class="modal">
    <div class="modal-title">
      <div style="padding-bottom: 1rem;">
-
        <Search size={20} on:search={() => dispatch("select")} />
+
        <Search size={20} {config} on:search />
      </div>
      <div>
        <a use:link on:click={() => dispatch("select")} href="/registrations">
deleted src/Search.spec.ts
@@ -1,34 +0,0 @@
-
import Search from "./Search.svelte";
-
import { fireEvent, render } from "@testing-library/svelte";
-
import "@public/index.css";
-

-
describe("Logic", () => {
-
  it("show a appropiate placeholder", () => {
-
    render(Search);
-
    cy.get("input").should(
-
      "have.attr",
-
      "placeholder",
-
      "Search a name or address…",
-
    );
-
  });
-

-
  it("allow input a query and navigates accordingly", () => {
-
    render(Search);
-
    cy.get("input").type("cloudhead.radicle.eth{enter}");
-
    cy.get("input").should("have.value", "cloudhead.radicle.eth");
-
    cy.url().should("contain", "/resolver/query?q=cloudhead.radicle.eth");
-
  });
-
});
-

-
describe("Events", () => {
-
  it("should fire an event when the input changes", () => {
-
    const { component } = render(Search);
-
    const mock = cy.spy();
-
    component.$on("search", mock);
-

-
    cy.get("input").then(([input]) => {
-
      fireEvent.keyDown(input, { key: "Enter" });
-
      expect(mock).to.have.been.calledOnce;
-
    });
-
  });
-
});
modified src/Search.svelte
@@ -1,16 +1,26 @@
<script lang="ts">
-
  import { navigate } from "svelte-routing";
+
  import { resolve, ResolvedSearch } from "@app/resolver";
+
  import type { Config } from "@app/config";
  import { createEventDispatcher } from "svelte";
+
  import Loading from "@app/Loading.svelte";

  export let size = 40;
+
  export let config: Config;

  let input = "";
+
  let searching = false;
+
  let results: ResolvedSearch | null;

  const dispatch = createEventDispatcher();
-
  const handleKeydown = (event: KeyboardEvent) => {
+
  const handleKeydown = async (event: KeyboardEvent) => {
    if (event.key === "Enter") {
-
      dispatch("search");
-
      navigate(`/resolver/query?${new URLSearchParams({ q: input })}`);
+
      searching = true;
+
      results = await resolve(input, config);
+
      if (results) {
+
        dispatch("search", { query: input, results });
+
      }
+
      input = "";
+
      searching = false;
    }
  };
</script>
@@ -26,11 +36,32 @@
    border-style: dashed;
    height: var(--button-regular-height);
  }
+
  input[disabled] {
+
    color: var(--color-secondary);
+
  }
+
  .wrapper {
+
    position: relative;
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
  }
+
  .loading {
+
    position: absolute;
+
    right: 12px;
+
  }
</style>

-
<input
-
  {size}
-
  type="text"
-
  bind:value={input}
-
  on:keydown={handleKeydown}
-
  placeholder="Search a name or address…" />
+
<div class="wrapper">
+
  <input
+
    {size}
+
    type="text"
+
    disabled={searching}
+
    bind:value={input}
+
    on:keydown={handleKeydown}
+
    placeholder="Search a name or address…" />
+
  {#if searching}
+
    <div class="loading">
+
      <Loading small />
+
    </div>
+
  {/if}
+
</div>
deleted src/base/resolver/List.svelte
@@ -1,46 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/Modal.svelte";
-
  import Address from "@app/Address.svelte";
-
  import type { Config } from "@app/config";
-
  import { Profile } from "@app/profile";
-
  import Loading from "@app/Loading.svelte";
-
  import Button from "@app/Button.svelte";
-

-
  export let config: Config;
-

-
  const { query } = window.history.state;
-
  const back = () => window.history.back();
-
</script>
-

-
<style>
-
  .list {
-
    display: flex;
-
    flex-direction: column;
-
    align-items: center;
-
  }
-
</style>
-

-
<Modal subtle>
-
  <span slot="title">️🔍</span>
-
  <span slot="subtitle">
-
    <p class="highlight txt-medium">
-
      <span class="txt-bold">Multiple names found for {query}</span>
-
    </p>
-
  </span>
-
  <span slot="body">
-
    <div class="list">
-
      {#await Profile.getMulti([`${query}.${config.registrar.domain}`, `${query}.eth`], config)}
-
        <Loading center />
-
      {:then profiles}
-
        {#each profiles as profile}
-
          {#if profile}
-
            <Address address={profile.address} {profile} {config} resolve />
-
          {/if}
-
        {/each}
-
      {/await}
-
    </div>
-
  </span>
-
  <span slot="actions">
-
    <Button variant="foreground" on:click={back}>Back</Button>
-
  </span>
-
</Modal>
deleted src/base/resolver/Query.svelte
@@ -1,62 +0,0 @@
-
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import { ethers } from "ethers";
-
  import { navigate } from "svelte-routing";
-
  import type { Config } from "@app/config";
-
  import * as utils from "@app/utils";
-
  import Error from "@app/Error.svelte";
-
  import Loading from "@app/Loading.svelte";
-

-
  export let config: Config;
-
  export let query: string | null;
-

-
  const error = false;
-

-
  onMount(async () => {
-
    if (query) {
-
      if (ethers.utils.isAddress(query)) {
-
        const addressType =
-
          query && (await utils.identifyAddress(query, config));
-
        if (
-
          addressType === utils.AddressType.Org ||
-
          addressType === utils.AddressType.EOA
-
        ) {
-
          navigate(`/${query}`, { replace: true });
-
        }
-
      } else if (utils.isRadicleId(query)) {
-
        // Go to Radicle project.
-
        navigate(`/projects/${query}`, { replace: true });
-
      } else {
-
        // Jump straight to org, if the ENS entry points to an org. Otherwise it checks if the
-
        // address type is an EOA and jumps to the user page else it just goes to the registration.
-
        const address = await utils.resolveLabel(query, config);
-
        const addressType =
-
          address && (await utils.identifyAddress(address, config));
-
        if (
-
          addressType === utils.AddressType.Org ||
-
          addressType === utils.AddressType.EOA
-
        ) {
-
          navigate(`/${address}`, { replace: true });
-
        } else {
-
          navigate(`/registrations/${query}`, { replace: true });
-
        }
-
      }
-
    } else {
-
      navigate("/");
-
    }
-
  });
-
</script>
-

-
<svelte:head>
-
  <title>Radicle: {query}</title>
-
</svelte:head>
-

-
<main class="off-centered">
-
  {#if error}
-
    <Error on:close={() => navigate("/")}>
-
      Invalid query string “{query}”
-
    </Error>
-
  {:else}
-
    <Loading center />
-
  {/if}
-
</main>
deleted src/base/resolver/Routes.svelte
@@ -1,12 +0,0 @@
-
<script lang="ts">
-
  import { Route } from "svelte-routing";
-
  import Resolve from "@app/base/resolver/Query.svelte";
-
  import type { Config } from "@app/config";
-
  import * as utils from "@app/utils";
-

-
  export let config: Config;
-
</script>
-

-
<Route path="/resolver/query" let:location>
-
  <Resolve {config} query={utils.getSearchParam("q", location)} />
-
</Route>
added src/components/Modal/SearchResults.svelte
@@ -0,0 +1,82 @@
+
<script lang="ts">
+
  import Modal from "@app/Modal.svelte";
+
  import { link } from "svelte-routing";
+
  import { formatRadicleUrn, getSeedEmoji } from "@app/utils";
+
  import type { Config } from "@app/config";
+
  import Address from "@app/Address.svelte";
+
  import Button from "@app/Button.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import type { ResolvedSearch } from "@app/resolver";
+

+
  export let query: string;
+
  export let results: ResolvedSearch;
+
  export let config: Config;
+

+
  const dispatch = createEventDispatcher();
+
</script>
+

+
<style>
+
  .results {
+
    text-align: left;
+
  }
+
  ul {
+
    list-style-type: none;
+
    padding: 0;
+
  }
+
  li {
+
    margin: 0.5rem 0;
+
  }
+
  .urn {
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<svelte:window on:click={() => dispatch("close")} />
+

+
<Modal center floating>
+
  <span slot="title">️🔍</span>
+
  <span slot="subtitle">
+
    <p class="highlight txt-medium">
+
      <span class="txt-bold">
+
        Results for <q>{query}</q>
+
      </span>
+
    </p>
+
  </span>
+
  <span class="results" slot="body">
+
    {#if results.projects.length > 0}
+
      <p class="highlight txt-medium">Projects</p>
+
      <ul>
+
        {#each results.projects as project}
+
          <li>
+
            <a use:link href="/seeds/{project.seed.host}/{project.info.urn}">
+
              <span title={project.seed.host}>
+
                <span>
+
                  {getSeedEmoji(project.seed.host, config)}&nbsp;{project.info
+
                    .name}
+
                </span>
+
                <span class="urn">
+
                  &nbsp;{formatRadicleUrn(project.info.urn)}
+
                </span>
+
              </span>
+
            </a>
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
    {#if results.ens.length > 0}
+
      <p class="highlight txt-medium">ENS names</p>
+
      <ul>
+
        {#each results.ens as profile}
+
          <li>
+
            <Address address={profile.address} {profile} {config} resolve />
+
          </li>
+
        {/each}
+
      </ul>
+
    {/if}
+
  </span>
+
  <span slot="actions">
+
    <Button variant="foreground" on:click={() => dispatch("close")}>
+
      Close
+
    </Button>
+
  </span>
+
</Modal>
added src/resolver.ts
@@ -0,0 +1,111 @@
+
import { Project } from "@app/project";
+
import type { ProjectInfo } from "@app/project";
+
import * as utils from "@app/utils";
+
import { ethers } from "ethers";
+
import type { Config } from "@app/config";
+
import { navigate } from "svelte-routing";
+
import type { Host } from "@app/api";
+
import { Profile, ProfileType } from "@app/profile";
+

+
export interface IProject {
+
  info: ProjectInfo;
+
  seed: Host;
+
}
+

+
export interface ResolvedSearch {
+
  projects: IProject[];
+
  ens: Profile[];
+
}
+

+
export async function resolve(
+
  q: string | null,
+
  config: Config,
+
): Promise<ResolvedSearch | null> {
+
  const results: ResolvedSearch = {
+
    projects: [],
+
    ens: [],
+
  };
+

+
  try {
+
    if (q) {
+
      const seeds = Object.keys(config.seeds.pinned).map(seed => ({
+
        urn: q as string,
+
        seed,
+
      }));
+

+
      if (ethers.utils.isAddress(q)) {
+
        navigate(`/${q}`, { replace: true });
+
        return null;
+

+
        // ========= Projects =========
+
      } else if (utils.isRadicleId(q)) {
+
        const projects = await Project.getMulti(seeds);
+
        if (projects.length === 1) {
+
          navigate(`/seeds/${projects[0].seed.host}/${q}`, {
+
            replace: true,
+
          });
+
          return null;
+
        } else {
+
          results.projects.push(...projects);
+
        }
+
      } else {
+
        const projects = await Project.getMulti(seeds);
+
        results.projects.push(...projects);
+

+
        // ========= ENS Names =========
+
        let profile: Profile | null;
+
        try {
+
          profile = await Profile.get(q, ProfileType.Minimal, config);
+
        } catch (e) {
+
          profile = null;
+
        }
+

+
        if (profile) {
+
          if (results.projects.length === 0) {
+
            navigate(`/${profile.address}`, { replace: true });
+
            return null;
+
          } else {
+
            results.ens.push(profile);
+
          }
+
        } else {
+
          let profiles: Profile[];
+
          try {
+
            profiles = await Profile.getMulti(
+
              [`${q}.${config.registrar.domain}`, `${q}.eth`],
+
              config,
+
            );
+
          } catch (e) {
+
            profiles = [];
+
          }
+

+
          if (profiles.length > 1) {
+
            results.ens.push(...profiles);
+
          } else if (profiles.length === 1) {
+
            if (results.projects.length === 0) {
+
              navigate(`/${profiles[0].address}`, { replace: true });
+
              return null;
+
            } else {
+
              results.ens.push(...profiles);
+
            }
+
          } else {
+
            if (results.projects.length === 1) {
+
              navigate(`/seeds/${projects[0].seed.host}/${q}`, {
+
                replace: true,
+
              });
+
              return null;
+
            }
+
          }
+
        }
+
      }
+
    }
+

+
    if (results.projects.length > 0 || results.ens.length > 0) {
+
      return results;
+
    }
+

+
    return null;
+
  } catch (e) {
+
    console.error(e);
+
    return null;
+
  }
+
}