Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement project browsing
Alexis Sellier committed 4 years ago
commit 6d313a4d45703cda00abb79aa04719928a0df3d6
parent e7aa71edd1f060b433ed986ec6d0d09b64b9018a
17 files changed +596 -51
modified public/index.css
@@ -19,10 +19,12 @@
	--color-secondary-2: #2c326d;
	--color-secondary-6: #e3e3ff;
	--color-tertiary: #55ffff;
+
	--color-tertiary-faded: #ade4e4;
	--color-tertiary-background: #55ffff11;
	--color-tertiary-1: #214047;
	--color-tertiary-2: #2c326d;
	--color-tertiary-6: #e3e3ff;
+
	--color-light: #add0e4;
	--color-yellow: #ffff99;
	--color-yellow-background: #ffff9911;
	--color-positive: #53db53;
@@ -39,7 +41,9 @@
	--color-negative-2: #623237;
	--color-negative-6: #ffd4d4;
	--color-foreground: #ffffff;
+
	--color-foreground-90: #dddddd;
	--color-foreground-faded: #777788;
+
	--color-foreground-subtle: #444455;
	--color-foreground-background: #77778811;
	--color-foreground-1: #242e38;
	--color-foreground-2: #29343d;
@@ -52,6 +56,8 @@
	--color-glow-error: #ff555522;

	--font-family-sans-serif: Inter, sans-serif;
+
	--font-weight-medium: 600;
+
	--font-weight-bold: 700;
	--font-family-monospace: monospace;
	--border-radius: 50px;
	--box-shadow-color: var(--color-secondary-2);
modified public/typography.css
@@ -16,7 +16,7 @@

@font-face {
	font-family: "Inter";
-
	font-style: bold;
+
	font-style: bolder;
	font-weight: 700;
	font-display: swap;
	src: url("fonts/Inter-Bold.otf");
modified snowpack.config.js
@@ -2,6 +2,7 @@
module.exports = {
  env: {
    RADICLE_ALCHEMY_API_KEY: process.env.RADICLE_ALCHEMY_API_KEY,
+
    RADICLE_HTTP_API: process.env.RADICLE_HTTP_API,
  },
  mount: {
    public: '/',
@@ -17,6 +18,7 @@ module.exports = {
  routes: [
    /* Enable an SPA Fallback in development: */
    {"match": "routes", "src": ".*", "dest": "/index.html"},
+
    {"match": "all", "src": "/projects/.*", "dest": "/index.html"},
  ],
  optimize: {
    /* Setting `bundle: true` breaks .json imports in snowpack 3.5.0 */
modified src/App.svelte
@@ -7,6 +7,7 @@
  import Vesting from '@app/base/vesting/Index.svelte';
  import Registrations from '@app/base/registrations/Routes.svelte';
  import Orgs from '@app/base/orgs/Routes.svelte';
+
  import Projects from '@app/base/projects/Routes.svelte';
  import Resolver from '@app/base/resolver/Routes.svelte';
  import Header from '@app/Header.svelte';
  import Loading from '@app/Loading.svelte';
@@ -55,6 +56,7 @@
        </Route>
        <Registrations {config} session={$session} />
        <Orgs {config} />
+
        <Projects {config} />
        <Resolver {config} />
      </Router>
    </div>
modified src/Header.svelte
@@ -135,7 +135,7 @@
      <span class="network unavailable">No Network</span>
    {/if}

-
    {#if address}
+
    {#if address && tokenBalance}
      <span class="balance">
        {formatBalance(tokenBalance)} <strong>RAD</strong>
      </span>
modified src/base/orgs/Org.ts
@@ -4,7 +4,7 @@ import type { ContractReceipt } from '@ethersproject/contracts';
import { assert } from '@app/error';
import * as utils from '@app/utils';
import type { Config } from '@app/config';
-
import type { Project } from '@app/base/projects/Project';
+
import type { Project } from '@app/project';

const GetProjects = `
  query GetProjects($org: ID!) {
@@ -13,6 +13,7 @@ const GetProjects = `
      anchor {
        stateHash
        stateHashFormat
+
        timestamp
      }
    }
  }
added src/base/projects/Blob.svelte
@@ -0,0 +1,89 @@
+
<script lang="typescript">
+
  import type { Blob } from "@app/project";
+

+
  export let blob: Blob;
+

+
  const lines = (blob.content.match(/\n/g) || []).length;
+
  const lineNumbers = Array(lines).fill(0).map((_, index) => (index + 1).toString());
+
</script>
+

+
<style>
+
  .file-source {
+
    border: 1px solid var(--color-foreground-level-3);
+
    border-radius: 0.5rem;
+
    min-width: var(--content-min-width);
+
  }
+

+
  header .file-header {
+
    display: flex;
+
    height: 3rem;
+
    align-items: center;
+
    padding: 0 1rem;
+
    color: var(--color-foreground);
+
    border-width: 1px 1px 0 1px;
+
    border-color: var(--color-foreground-subtle);
+
    border-style: solid;
+
    border-top-left-radius: 0.25rem;
+
    border-top-right-radius: 0.25rem;
+
  }
+

+
  header .file-name {
+
    font-weight: normal;
+
  }
+

+
  .line-numbers {
+
    color: var(--color-foreground-subtle);
+
    font-family: var(--font-family-sans-serif);
+
    text-align: right;
+
    user-select: none;
+
    padding: 0 1rem 0.5rem 1rem;
+
  }
+

+
  .code {
+
    padding-bottom: 0.5rem;
+
    overflow-x: auto;
+
  }
+

+
  .container {
+
    display: flex;
+
    border: 1px solid var(--color-foreground-subtle);
+
    border-top-style: dashed;
+
  }
+

+
  .no-scrollbar {
+
    scrollbar-width: none;
+
  }
+

+
  .no-scrollbar::-webkit-scrollbar {
+
    display: none;
+
  }
+
</style>
+

+
<div>
+
  <div class="file-source">
+
    <header>
+
      <div class="file-header" data-cy="file-header">
+
        <span class="file-name">
+
          <span>{blob.path.split("/").join(" / ")}</span>
+
        </span>
+
      </div>
+
    </header>
+
    <div class="container">
+
      {#if blob.binary}
+
        👀 Binary content
+
      {:else}
+
        <pre class="line-numbers">
+
          {@html lineNumbers.join("\n")}
+
        </pre>
+
        <pre
+
          class="code no-scrollbar">
+
          {#if blob.html}
+
            {@html blob.content}
+
          {:else}
+
            {blob.content}
+
          {/if}
+
        </pre>
+
      {/if}
+
    </div>
+
  </div>
+
</div>
deleted src/base/projects/Project.ts
@@ -1,35 +0,0 @@
-
import type { Config } from '@app/config';
-

-
export interface Project {
-
  id: string
-
  anchor: {
-
    stateHash: string
-
    stateHashFormat: string
-
  }
-
}
-

-
export interface User {
-
  urn: string
-
  avatar: { emoji: string, background: { r: number, g: number, b: number } }
-
}
-

-
export interface Meta {
-
  name: string
-
  description: string
-
  maintainers: User[]
-
}
-

-
export async function getMetadata(urn: string, config: Config): Promise<Meta | null> {
-
  const response = await fetch(`${config.seed.url}/projects/${urn}`, {
-
    method: 'GET',
-
    headers: {
-
      'Accept': 'application/json',
-
    }
-
  });
-

-
  if (! response.ok) {
-
    return null;
-
  }
-

-
  return await response.json();
-
}
added src/base/projects/Routes.svelte
@@ -0,0 +1,19 @@
+
<script lang="typescript">
+
  import { Route } from "svelte-routing";
+
  import View from '@app/base/projects/View.svelte';
+
  import type { Config } from '@app/config';
+

+
  export let config: Config;
+

+
  const joinPaths = (path: string, glob: string): string => {
+
    return glob.length > 0 ? [path, glob].join("/") : path;
+
  };
+
</script>
+

+
<Route path="/projects/:urn/:commit/:path/*" let:params>
+
  <View {config} urn={params.urn} commit={params.commit} path={joinPaths(params.path, params['*'])} />
+
</Route>
+

+
<Route path="/projects/:urn/:commit" let:params>
+
  <View {config} urn={params.urn} commit={params.commit} path="/" />
+
</Route>
added src/base/projects/Tree.svelte
@@ -0,0 +1,37 @@
+
<script lang="typescript">
+
  import { createEventDispatcher } from "svelte";
+

+
  import type { Tree } from "@app/project";
+
  import { ObjectType } from "@app/project";
+

+
  import File from './Tree/File.svelte';
+
  import Folder from './Tree/Folder.svelte';
+

+
  export let fetchTree: (path: string) => Promise<Tree>;
+
  export let path: string;
+
  export let tree: Tree;
+

+
  const dispatch = createEventDispatcher();
+
  const onSelect = ({ detail: path }: { detail: string }): void => {
+
    dispatch("select", path);
+
  };
+
</script>
+

+
{#each tree.entries as entry (entry.path)}
+
  {#if entry.info.objectType === ObjectType.Tree}
+
    <Folder
+
      {fetchTree}
+
      name={entry.info.name}
+
      prefix={`${entry.path}/`}
+
      currentPath={path}
+
      on:select={onSelect}
+
    />
+
  {:else}
+
    <File
+
      active={entry.path === path}
+
      loading={false}
+
      name={entry.info.name}
+
      on:click={() => onSelect({ detail: entry.path })}
+
    />
+
  {/if}
+
{/each}
added src/base/projects/Tree/File.svelte
@@ -0,0 +1,55 @@
+
<script lang="typescript">
+
  import Loading from '@app/Loading.svelte';
+

+
  export let active: boolean;
+
  export let loading: boolean;
+
  export let name: string;
+
</script>
+

+
<style>
+
  .file {
+
    color: var(--color-foreground-90);
+
    border-radius: 0.25rem;
+
    cursor: pointer;
+
    display: flex;
+
    flex: 1;
+
    line-height: 1.5em;
+
    margin: 0.125rem 0;
+
    padding: 0.25rem;
+
    width: 100%;
+
  }
+

+
  .file:hover {
+
    background-color: var(--color-foreground-background);
+
  }
+

+
  .file.active {
+
    color: var(--color-foreground) !important;
+
    background-color: var(--color-foreground-background);
+
  }
+

+
  .spinner {
+
    align-items: center;
+
    display: flex;
+
    justify-content: center;
+
    height: 24px;
+
    width: 24px;
+
  }
+

+
  .name {
+
    margin-left: 0.25rem;
+
    user-select: none;
+
    white-space: nowrap;
+
  }
+
</style>
+

+
<div class="file" class:active on:click>
+
  {#if loading}
+
    <div class="spinner">
+
      <Loading small />
+
    </div>
+
  {:else}
+
    <!-- Nothing -->
+
  {/if}
+
  <span class="name">{name}</span>
+
</div>
added src/base/projects/Tree/Folder.svelte
@@ -0,0 +1,89 @@
+
<script lang="typescript">
+
  import { createEventDispatcher } from "svelte";
+

+
  import Loading from '@app/Loading.svelte';
+
  import type { Tree } from "@app/project";
+
  import { ObjectType } from "@app/project";
+

+
  import File from "./File.svelte";
+

+
  export let fetchTree: (path: string) => Promise<Tree>;
+
  export let name: string;
+
  export let prefix: string;
+
  export let currentPath: string;
+

+
  let expanded = currentPath.indexOf(prefix) === 0;
+
  let tree: Promise<Tree> | null = expanded ? fetchTree(prefix) : null;
+

+
  const dispatch = createEventDispatcher();
+
  const onSelectFile = ({ detail: path }: { detail: string }) =>
+
    dispatch("select", path);
+

+
  const onClick = () => {
+
    expanded = !expanded;
+

+
    if (expanded) {
+
      tree = fetchTree(prefix);
+
    }
+
  };
+
</script>
+

+
<style>
+
  .folder {
+
    display: flex;
+
    cursor: pointer;
+
    padding: 0.25rem;
+
    margin: 0.125rem 0;
+
    color: var(--color-foreground-level-6);
+
    user-select: none;
+
    line-height: 1.5rem;
+
    white-space: nowrap;
+
  }
+
  .folder:hover {
+
    background-color: var(--color-foreground-background);
+
    border-radius: 0.25rem;
+
  }
+

+
  .folder-name {
+
    margin-left: 0.25rem;
+
    color: var(--color-light);
+
  }
+

+
  .container {
+
    padding-left: 0.5rem;
+
    margin: 0;
+
  }
+
</style>
+

+
<div class="folder" on:click={onClick}>
+
  <span class="folder-name">{name}/</span>
+
</div>
+

+
<div class="container">
+
  {#if expanded}
+
    {#await tree}
+
      <Loading small center />
+
    {:then tree}
+
      {#if tree}
+
        {#each tree.entries as entry (entry.path)}
+
          {#if entry.info.objectType === ObjectType.Tree}
+
            <svelte:self
+
              {fetchTree}
+
              name={entry.info.name}
+
              on:select={onSelectFile}
+
              prefix={`${entry.path}/`}
+
              {currentPath} />
+
          {:else}
+
            <File
+
              active={entry.path === currentPath}
+
              loading={false}
+
              name={entry.info.name}
+
              on:click={() => {
+
                onSelectFile({ detail: entry.path });
+
              }} />
+
          {/if}
+
        {/each}
+
      {/if}
+
    {/await}
+
  {/if}
+
</div>
added src/base/projects/View.svelte
@@ -0,0 +1,143 @@
+
<script lang="typescript">
+
  import { onMount } from 'svelte';
+
  import { navigate } from 'svelte-routing';
+
  import type { Config } from '@app/config';
+
  import * as proj from '@app/project';
+
  import Loading from '@app/Loading.svelte';
+

+
  import Tree from './Tree.svelte';
+
  import Blob from './Blob.svelte';
+

+
  export let urn: string;
+
  export let commit: string;
+
  export let config: Config;
+
  export let path: string;
+

+
  let meta: "loading" | proj.Meta | null = null;
+
  let blob: Promise<proj.Blob | null> | null = null;
+

+
  const fetchTree = async (path: string) => {
+
    return proj.getTree(urn, commit, path, config);
+
  };
+

+
  const onSelect = async ({ detail: path }: { detail: string }) => {
+
    navigate(`/projects/${urn}/${commit}/${path}`);
+
  };
+

+
  onMount(async () => {
+
    meta = "loading";
+
    meta = await proj.getMetadata(urn, config);
+
  });
+

+
  $: if (path === "/") {
+
    blob = proj.getReadme(urn, commit, config);
+
  } else {
+
    blob = proj.getBlob(urn, commit, path, config);
+
  }
+
</script>
+

+
<style>
+
  main {
+
    width: 100%;
+
    padding: 4rem 0;
+
  }
+
  main > header {
+
    margin-bottom: 4rem;
+
    padding: 0 8rem;
+
  }
+
  .title {
+
    font-size: 2.25rem;
+
    margin-bottom: 0.5rem;
+
  }
+

+
  .anchor {
+
    display: inline-block;
+
    font-size: 0.75rem;
+
    font-family: var(--font-family-monospace);
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-background);
+
    padding: 0.75rem;
+
    border-radius: 0.25rem;
+
  }
+

+
  .urn {
+
    font-family: var(--font-family-monospace);
+
    font-size: 0.75rem;
+
    color: var(--color-foreground-faded);
+
  }
+
  .description {
+
    margin: 1rem 0 1.5rem 0;
+
  }
+

+
  .center-content {
+
    margin: 0 auto;
+
    max-width: var(--content-max-width);
+
    min-width: var(--content-min-width);
+
  }
+

+
  .container {
+
    display: flex;
+
    width: inherit;
+
    margin-bottom: 4rem;
+
    padding: 0 2rem 0 8rem;
+
  }
+

+
  .column-left {
+
    display: flex;
+
    flex-direction: column;
+
    padding-right: 1rem;
+
  }
+

+
  .column-right {
+
    display: flex;
+
    flex-direction: column;
+
    padding-left: 1rem;
+
    min-width: var(--content-min-width);
+
    width: 100%;
+
  }
+

+
  .source-tree {
+
    overflow-x: auto;
+
  }
+
</style>
+

+
<main>
+
  <header>
+
    {#if meta === "loading"}
+
      <Loading small />
+
    {:else if meta}
+
      <div class="title bold">{meta.name}</div>
+
      <div class="urn">{urn}</div>
+
      <div class="description">{meta.description}</div>
+
    {/if}
+
    <div class="anchor">
+
      commit {commit}
+
    </div>
+
  </header>
+
  <div class="container center-content">
+
    <div class="column-left">
+
      <div class="source-tree">
+
        {#await proj.getTree(urn, commit, "/", config)}
+
          Loading..
+
        {:then tree}
+
          <Tree {tree} {path} {fetchTree} on:select={onSelect} />
+
        {:catch err}
+
          {err}
+
        {/await}
+
      </div>
+
    </div>
+
    <div class="column-right">
+
      {#await blob}
+
        <Loading small center />
+
      {:then blob}
+
        {#if blob}
+
          <Blob {blob} />
+
        {:else}
+
          <!-- Project has no README -->
+
        {/if}
+
      {:catch}
+
        <!-- TODO: Handle error -->
+
      {/await}
+
    </div>
+
  </div>
+
</main>
modified src/base/projects/Widget.svelte
@@ -1,12 +1,10 @@
<script type="typescript">
  import { onMount } from 'svelte';
  import type { Config } from '@app/config';
+
  import * as proj from '@app/project';
  import Loading from '@app/Loading.svelte';
  import Blockies from '@app/Blockies.svelte';

-
  import { getMetadata } from './Project';
-
  import type { Project, Meta } from './Project';
-

  enum Status { Loading, Loaded, Error }

  type State =
@@ -14,15 +12,15 @@
    | { status: Status.Loaded }
    | { status: Status.Error, error: string };

-
  export let project: Project;
+
  export let project: proj.Project;
  export let config: Config;

  let state: State = { status: Status.Loading };
-
  let meta: Meta | null = null;
+
  let meta: proj.Meta | null = null;

  onMount(async () => {
    try {
-
      const result = await getMetadata(project.id, config);
+
      const result = await proj.getMetadata(project.id, config);
      state = { status: Status.Loaded };
      meta = result;
    } catch (err) {
modified src/config.json
@@ -58,9 +58,6 @@
    },
    "tokens": []
  },
-
  "seed": {
-
    "url": "https://sprout.radicle.xyz"
-
  },
  "abi": {
    "registrar": [
      "function rad() view returns (address)",
modified src/config.ts
@@ -11,6 +11,7 @@ declare global {
  interface ImportMeta {
    env: {
      RADICLE_ALCHEMY_API_KEY: string | null,
+
      RADICLE_HTTP_API: string | null,
    }
  }
}
@@ -24,9 +25,9 @@ export class Config {
  gasLimits: { createOrg: number };
  provider: ethers.providers.JsonRpcProvider;
  signer: ethers.Signer & TypedDataSigner | null;
-
  seed: { url: string };
  safe: { api: string | null, viewer: string | null };
  abi: { [contract: string]: string[] }
+
  seed: { api: string | null };
  tokens: string[];

  constructor(
@@ -34,13 +35,15 @@ export class Config {
    provider: ethers.providers.JsonRpcProvider,
    signer: ethers.Signer & TypedDataSigner | null,
  ) {
-
    let cfg = (<Record<string, any>> config)[network.name];
-
    
+
    const cfg = (<Record<string, any>> config)[network.name];
+
    const api = import.meta.env.RADICLE_HTTP_API;
+

    if (!cfg) {
      throw `Network ${network.name} is not supported`;
    }

    this.network = network;
+
    this.seed = { api };
    this.registrar = cfg.registrar;
    this.radToken = cfg.radToken;
    this.orgFactory = cfg.orgFactory;
@@ -49,7 +52,6 @@ export class Config {
    this.provider = provider;
    this.signer = signer;
    this.gasLimits = gasLimits;
-
    this.seed = config.seed;
    this.abi = config.abi;
    this.tokens = cfg.tokens;
  }
added src/project.ts
@@ -0,0 +1,140 @@
+
import type { Config } from '@app/config';
+

+
export interface Project {
+
  id: string
+
  anchor: {
+
    stateHash: string
+
    stateHashFormat: string
+
  }
+
}
+

+
export interface Person {
+
  urn: string
+
  avatar: { emoji: string, background: { r: number, g: number, b: number } }
+
}
+

+
export interface Meta {
+
  name: string
+
  description: string
+
  maintainers: Person[]
+
}
+

+
export interface Author {
+
  avatar: string;
+
  email: string;
+
  name: string;
+
}
+

+
export enum ObjectType {
+
  Blob = "BLOB",
+
  Tree = "TREE",
+
}
+

+
export interface CommitHeader {
+
  author: Author;
+
  committer: Author;
+
  committerTime: number;
+
  description: string;
+
  sha1: string;
+
  summary: string;
+
}
+

+
export interface Info {
+
  name: string;
+
  objectType: ObjectType;
+
  lastCommit: CommitHeader;
+
}
+

+
export interface Entry {
+
  path: string;
+
  info: Info;
+
}
+

+
export interface Blob {
+
  binary?: boolean;
+
  html?: boolean;
+
  content: string;
+
  path: string;
+
  info: Info;
+
}
+

+
export interface Tree {
+
  entries: Entry[];
+
  info: Info;
+
  path: string;
+
}
+

+
export async function getMetadata(urn: string, config: Config): Promise<Meta | null> {
+
  const api = import.meta.env.RADICLE_HTTP_API;
+
  const response = await fetch(`${api}/v1/projects/${urn}`, {
+
    method: 'GET',
+
    headers: {
+
      'Accept': 'application/json',
+
    }
+
  });
+

+
  if (! response.ok) {
+
    return null;
+
  }
+

+
  return await response.json();
+
}
+

+
export async function getTree(
+
  urn: string,
+
  commit: string,
+
  path: string,
+
  config: Config
+
): Promise<any | null> {
+
  if (! config.seed.api) return null;
+

+
  const response = await fetch(`${config.seed.api}/v1/projects/${urn}/tree/${commit}/${path}`, {
+
    method: 'GET',
+
    headers: {
+
      'Accept': 'application/json',
+
    }
+
  });
+
  if (! response.ok) {
+
    return null;
+
  }
+
  return response.json();
+
}
+

+
export async function getBlob(
+
  urn: string,
+
  commit: string,
+
  path: string,
+
  config: Config
+
): Promise<Blob | null> {
+
  if (! config.seed.api) return null;
+

+
  const response = await fetch(`${config.seed.api}/v1/projects/${urn}/blob/${commit}/${path}`, {
+
    method: 'GET',
+
    headers: {
+
      'Accept': 'application/json',
+
    }
+
  });
+
  if (! response.ok) {
+
    return null;
+
  }
+
  return response.json();
+
}
+

+
export async function getReadme(
+
  urn: string,
+
  commit: string,
+
  config: Config
+
): Promise<Blob | null> {
+
  if (! config.seed.api) return null;
+

+
  const response = await fetch(`${config.seed.api}/v1/projects/${urn}/readme/${commit}`, {
+
    method: 'GET',
+
    headers: {
+
      'Accept': 'application/json',
+
    }
+
  });
+
  if (! response.ok) {
+
    return null;
+
  }
+
  return response.json();
+
}