Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add connect to node button in header
Rūdolfs Ošiņš committed 3 years ago
commit b8b203514b60bae526c55356f71e070116553107
parent 2c5830a076fe08dc01c87476263f11ac9f396c9f
12 files changed +393 -30
modified scripts/run-httpd-with-fixtures
@@ -1,7 +1,7 @@
#!/bin/bash
set -e

-
REV=a5811e43a74043e3bf1e706720bac3ec01b079c4
+
REV=f1de61ad8897a845f2be49e3e2b2951bc678b678

REPO_ROOT=$(git rev-parse --show-toplevel)
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm-heartwood.tar.bz2
@@ -77,9 +77,9 @@ if [ "$DOWNLOAD" = true ]; then
  if ! [ -x "$(command -v $BINARY_PATH/$CACHED_BINARY_NAME)" ]; then
    echo "Downloading $BINARY_NAME"
    case "$OS" in
-
      Darwin)  curl -s "https://storage.googleapis.com/heartwood-artifacts/${REV}/aarch64-apple-darwin/radicle-httpd" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
-
      Linux)   curl -s "https://storage.googleapis.com/heartwood-artifacts/${REV}/x86_64-unknown-linux-musl/radicle-httpd" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
-
      *)       echo "There are no precompiled binaries for your OS: $OS, compile radicle-httpd manually and make sure it's in PATH." && exit 1 ;;
+
      Darwin)  curl -s "https://storage.googleapis.com/heartwood-artifacts/$REV/aarch64-apple-darwin/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
+
      Linux)   curl -s "https://storage.googleapis.com/heartwood-artifacts/$REV/x86_64-unknown-linux-musl/$BINARY_NAME" --output "$BINARY_PATH/$CACHED_BINARY_NAME" ;;
+
      *)       echo "There are no precompiled binaries for your OS: $OS, compile $BINARY_NAME manually and make sure it's in PATH." && exit 1 ;;
    esac

    chmod a+x "$BINARY_PATH/$CACHED_BINARY_NAME"
modified src/App.svelte
@@ -1,7 +1,8 @@
<script lang="ts">
  import Plausible from "plausible-tracker";

-
  import { initialize, activeRouteStore } from "@app/lib/router";
+
  import * as router from "@app/lib/router";
+
  import * as session from "@app/lib/session";
  import { unreachable } from "@app/lib/utils";

  import Header from "./App/Header.svelte";
@@ -14,7 +15,10 @@
  import Projects from "@app/views/projects/View.svelte";
  import Seeds from "@app/views/seeds/Routes.svelte";

-
  initialize();
+
  const activeRouteStore = router.activeRouteStore;
+

+
  router.initialize();
+
  session.initialize();

  if (!window.VITEST && !window.PLAYWRIGHT && import.meta.env.PROD) {
    const plausible = Plausible({
modified src/App/Header.svelte
@@ -3,6 +3,7 @@
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import SettingsDropdown from "./Header/SettingsDropdown.svelte";
+
  import Connect from "@app/App/Header/Connect.svelte";

  import Logo from "./Header/Logo.svelte";
  import Search from "./Header/Search.svelte";
@@ -70,6 +71,7 @@
  </div>

  <div class="right">
+
    <Connect />
    <Floating>
      <div slot="toggle">
        <button class="toggle" name="Settings">
added src/App/Header/Connect.svelte
@@ -0,0 +1,236 @@
+
<script lang="ts">
+
  import debounce from "lodash/debounce";
+

+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { sessionStore, disconnect } from "@app/lib/session";
+
  import { toClipboard } from "@app/lib/utils";
+

+
  import Avatar from "@app/components/Comment/Avatar.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

+
  function formatId(id: string) {
+
    return id.substring(0, 4) + " – " + id.substring(id.length - 4, id.length);
+
  }
+

+
  let icon: "clipboard-small" | "checkmark-small" = "clipboard-small";
+

+
  const restoreIcon = debounce(() => {
+
    icon = "clipboard-small";
+
  }, 800);
+

+
  function copyToClipboard(clipboard: string) {
+
    toClipboard(clipboard);
+
    icon = "checkmark-small";
+
    restoreIcon();
+
  }
+

+
  $: command = import.meta.env.PROD
+
    ? "rad web"
+
    : `rad web --frontend ${new URL(import.meta.url).origin}`;
+
</script>
+

+
<style>
+
  .dropdown {
+
    align-items: center;
+
    background: var(--color-background-1);
+
    border-radius: var(--border-radius);
+
    box-shadow: var(--elevation-low);
+
    color: var(--color-foreground-6);
+
    display: flex;
+
    flex-direction: column;
+
    position: absolute;
+
    right: 5rem;
+
    top: 5rem;
+
    width: 16.5rem;
+
  }
+
  .info {
+
    align-items: flex-start;
+
    padding: 1rem;
+
    width: 20.5rem;
+
  }
+
  .cmd {
+
    background-color: var(--color-foreground-3);
+
    border-radius: var(--border-radius-small);
+
    cursor: pointer;
+
    display: inline-block;
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-small);
+
    margin-top: 0.5rem;
+
    max-width: 18.5rem;
+
    overflow: hidden;
+
    padding: 2px 0.5rem;
+
    position: relative;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .cmd-clipboard {
+
    align-items: center;
+
    background-image: linear-gradient(
+
      -90deg,
+
      var(--color-foreground-2),
+
      var(--color-foreground-2),
+
      transparent
+
    );
+
    display: flex;
+
    justify-content: flex-end;
+
    position: absolute;
+
    right: 0;
+
    top: 0;
+
    visibility: hidden;
+
    width: 3rem;
+
  }
+
  .cmd:hover .cmd-clipboard {
+
    visibility: visible;
+
  }
+
  .avatar-id-container {
+
    align-items: center;
+
    border-top-left-radius: var(--border-radius);
+
    border-top-right-radius: var(--border-radius);
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    padding: 2rem 1rem;
+
    width: 100%;
+
  }
+
  .id-container {
+
    align-items: center;
+
    cursor: pointer;
+
    display: flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    justify-content: center;
+
  }
+
  .id-container:hover {
+
    color: var(--color-foreground);
+
  }
+
  .id-container:hover .id-clipboard {
+
    visibility: visible;
+
  }
+
  .id-clipboard {
+
    position: absolute;
+
    right: 1rem;
+
    visibility: hidden;
+
  }
+
  .id {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-tiny);
+
    user-select: none;
+
    width: 11rem;
+
    word-break: break-all;
+
  }
+
  .disconnect {
+
    align-items: center;
+
    border-top: 1px solid var(--color-foreground-3);
+
    cursor: pointer;
+
    display: flex;
+
    flex-direction: row;
+
    font-weight: 600;
+
    height: 2.5rem;
+
    justify-content: space-between;
+
    line-height: 2.5rem;
+
    padding: 0 0.8rem;
+
    user-select: none;
+
    width: 100%;
+
  }
+
  .disconnect:hover {
+
    background-color: var(--color-foreground-3);
+
    border-bottom-left-radius: var(--border-radius);
+
    border-bottom-right-radius: var(--border-radius);
+
    color: var(--color-foreground-6);
+
  }
+
  .toggle-avatar {
+
    align-items: center;
+
    display: flex;
+
    gap: 0.5rem;
+
    justify-content: center;
+
  }
+
  .dropdown-avatar {
+
    align-items: center;
+
    display: flex;
+
    height: 80px;
+
    justify-content: center;
+
    margin-bottom: 1rem;
+
  }
+
</style>
+

+
<Floating>
+
  <div slot="toggle">
+
    <Button
+
      style={$sessionStore
+
        ? "padding-left: 10px; padding-right: 1rem;"
+
        : undefined}
+
      variant="foreground">
+
      {#if $sessionStore}
+
        <div class="toggle-avatar">
+
          <div style:height="1.5rem">
+
            <Avatar
+
              source={$sessionStore.publicKey}
+
              title={$sessionStore.publicKey} />
+
          </div>
+
          <div class="user-id txt-small">
+
            {formatId($sessionStore.publicKey)}
+
          </div>
+
        </div>
+
      {:else}
+
        Connect
+
      {/if}
+
    </Button>
+
  </div>
+

+
  <div slot="modal">
+
    {#if !$sessionStore}
+
      <div class="dropdown info">
+
        To connect to your local Radicle node, run this command in your
+
        terminal:
+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <span
+
          class="cmd"
+
          on:click={() => {
+
            copyToClipboard(command);
+
          }}>
+
          {command}
+
          <div class="cmd-clipboard">
+
            <Icon name={icon} />
+
          </div>
+
        </span>
+
      </div>
+
    {:else}
+
      <div class="dropdown">
+
        <div class="avatar-id-container">
+
          <div class="dropdown-avatar">
+
            <Avatar
+
              source={$sessionStore.publicKey}
+
              title={$sessionStore.publicKey} />
+
          </div>
+
          <!-- svelte-ignore a11y-click-events-have-key-events -->
+
          <div
+
            class="id-container"
+
            on:click={() => {
+
              if ($sessionStore) {
+
                copyToClipboard($sessionStore.publicKey);
+
              }
+
            }}>
+
            <div class="id">
+
              {$sessionStore.publicKey}
+
            </div>
+
            <div class="id-clipboard">
+
              <Icon name={icon} />
+
            </div>
+
          </div>
+
        </div>
+

+
        <!-- svelte-ignore a11y-click-events-have-key-events -->
+
        <div
+
          class="disconnect"
+
          on:click={() => {
+
            disconnect();
+
            closeFocused();
+
          }}>
+
          Disconnect
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
</Floating>
modified src/components/Button.svelte
@@ -119,7 +119,7 @@
  {disabled}
  {style}
  {autofocus}
-
  on:click|stopPropagation
+
  on:click
  on:focus
  on:blur
  on:mouseout
modified src/components/Icon.svelte
@@ -3,16 +3,18 @@

  export let name:
    | "browse"
+
    | "checkmark"
+
    | "checkmark-small"
+
    | "chevron-down"
+
    | "chevron-up"
    | "clipboard"
    | "clipboard-small"
    | "ellipsis"
    | "fork"
+
    | "gear"
    | "moon"
-
    | "checkmark"
    | "sun"
-
    | "gear"
-
    | "chevron-down"
-
    | "chevron-up";
+
    | "twitter";
</script>

<style>
@@ -224,6 +226,16 @@
      14.1583 5.45118 13.8417 5.64645 13.6465C5.84171 13.4512 6.15829 13.4512 6.35355
      13.6465L8.69852 15.9914C9.35028 16.6432 10.4302 16.5584 10.9723 15.813L17.5956
      6.70592C17.7581 6.48259 18.0708 6.43322 18.2941 6.59564Z" />
+
  {:else if name === "checkmark-small"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M16.2481 8.56588C16.4878 8.70288 16.5711 9.00831 16.4341
+
      9.24807L13.0837 15.1113C12.593 15.9701 11.42 16.1271 10.7207
+
      15.4278L8.64645 13.3536C8.45118 13.1583 8.45118 12.8417 8.64645
+
      12.6464C8.84171 12.4512 9.15829 12.4512 9.35355 12.6464L11.4278
+
      14.7207C11.6609 14.9538 12.0519 14.9014 12.2154 14.6152L15.5659
+
      8.75193C15.7029 8.51217 16.0083 8.42887 16.2481 8.56588Z" />
  {:else if name === "sun"}
    <path
      fill-rule="evenodd"
modified src/components/Loading.svelte
@@ -29,7 +29,6 @@
    margin: auto 0;
    width: 70px;
    text-align: center;
-
    cursor: wait;
    display: flex;
    justify-content: space-evenly;
    align-items: center;
added src/lib/session.ts
@@ -0,0 +1,80 @@
+
import { derived, get, writable } from "svelte/store";
+

+
interface Session {
+
  id: string;
+
  publicKey: string;
+
}
+
const store = writable<Session | undefined>(undefined);
+
export const sessionStore = derived(store, s => s);
+

+
export async function authenticate(params: {
+
  id: string;
+
  signature: string;
+
  publicKey: string;
+
}): Promise<"success" | "failure"> {
+
  disconnect();
+

+
  const request = await fetch(
+
    `http://0.0.0.0:8080/api/v1/sessions/${params.id}`,
+
    {
+
      method: "PUT",
+
      headers: { "Content-Type": "application/json" },
+
      body: JSON.stringify({ sig: params.signature, pk: params.publicKey }),
+
    },
+
  );
+
  if (request.ok) {
+
    save(params.id, params.publicKey);
+
    return "success";
+
  } else {
+
    return "failure";
+
  }
+
}
+

+
export async function disconnect() {
+
  const session = get(store);
+
  if (!session) {
+
    return "success";
+
  }
+

+
  await fetch(`http://0.0.0.0:8080/api/v1/sessions/${session.id}`, {
+
    method: "DELETE",
+
    headers: {
+
      "Content-Type": "application/json",
+
      Authorization: `Bearer ${session.id}`,
+
    },
+
  });
+

+
  window.localStorage.removeItem("session");
+
  store.set(undefined);
+
}
+

+
function save(id: string, publicKey: string) {
+
  window.localStorage.setItem("session", JSON.stringify({ id, publicKey }));
+
  store.set({ id, publicKey });
+
}
+

+
export function initialize() {
+
  // Sync session state changes with other open tabs and windows.
+
  addEventListener("storage", event => {
+
    if (event.key === "session") {
+
      if (event.newValue === null) {
+
        store.set(undefined);
+
      } else {
+
        const parsed = JSON.parse(event.newValue);
+

+
        if (parsed.id && parsed.publicKey) {
+
          store.set({ id: parsed.id, publicKey: parsed.publicKey });
+
        }
+
      }
+
    }
+
  });
+

+
  const session = window.localStorage.getItem("session");
+

+
  if (session) {
+
    const parsed = JSON.parse(session);
+
    if (parsed.id && parsed.publicKey) {
+
      store.set({ id: parsed.id, publicKey: parsed.publicKey });
+
    }
+
  }
+
}
added src/views/session/AuthenticatedModal.svelte
@@ -0,0 +1,10 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+
</script>
+

+
<Modal title="Authenticated" emoji="🤝">
+
  <div slot="subtitle">
+
    You're now connected to your <br />
+
    local Radicle node.
+
  </div>
+
</Modal>
added src/views/session/AuthenticationErrorModal.svelte
@@ -0,0 +1,12 @@
+
<script lang="ts">
+
  import Modal from "@app/components/Modal.svelte";
+

+
  export let title: string;
+
  export let subtitle: string[];
+
</script>
+

+
<Modal {title} emoji="🚨">
+
  <div slot="subtitle">
+
    {@html subtitle.join("<br />")}
+
  </div>
+
</Modal>
modified src/views/session/Index.svelte
@@ -1,28 +1,38 @@
<script lang="ts">
  import type { Route } from "@app/lib/router/definitions";

+
  import { onMount } from "svelte";
+

+
  import * as modal from "@app/lib/modal";
  import * as router from "@app/lib/router";
+
  import * as session from "@app/lib/session";
  import Loading from "@app/components/Loading.svelte";

+
  import AuthenticatedModal from "@app/views/session/AuthenticatedModal.svelte";
+
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
+

  export let activeRoute: Extract<Route, { resource: "session" }>;

-
  async function createSession() {
-
    const { id, signature, publicKey } = activeRoute.params;
+
  onMount(async () => {
+
    const status = await session.authenticate(activeRoute.params);

-
    const request = await fetch(`http://0.0.0.0:8080/api/v1/sessions/${id}`, {
-
      method: "PUT",
-
      headers: { "Content-Type": "application/json" },
-
      body: JSON.stringify({ sig: signature, pk: publicKey }),
-
    });
-
    if (request.ok) {
-
      window.sessionStorage.setItem("session_id", id);
+
    if (status === "success") {
+
      modal.show({ component: AuthenticatedModal, props: {} });
+
    } else {
+
      modal.show({
+
        component: AuthenticationErrorModal,
+
        props: {
+
          title: "Authentication failed",
+
          subtitle: [
+
            "There was an error while authenticating.",
+
            "Check your radicle-httpd logs for details.",
+
          ],
+
        },
+
      });
    }
-
    // We currently push once logged in users to the landing page,
-
    // we should trigger some notification to inform the user what happened
+

    router.push({ resource: "home" });
-
  }
+
  });
</script>

-
{#await createSession()}
-
  <Loading center />
-
{/await}
+
<Loading center />
modified vite.config.ts
@@ -42,9 +42,7 @@ export default defineConfig({
    configurePreviewServer(),
  ],
  server: {
-
    // We have to set host here, otherwise CI binds to the ipv6 address and
-
    // e2e tests don't work.
-
    host: "127.0.0.1",
+
    host: "localhost",
    port: 3000,
  },
  resolve: {