Implement project browsing

This commit is contained in:
Alexis Sellier
2021-06-09 20:53:35 +02:00
parent e7aa71edd1
commit 6d313a4d45
17 changed files with 596 additions and 51 deletions
+6
View File
@@ -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);
+1 -1
View File
@@ -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");
+2
View File
@@ -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 */
+2
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -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
}
}
}
+89
View File
@@ -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>
-35
View File
@@ -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();
}
+19
View File
@@ -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>
+37
View File
@@ -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}
+55
View File
@@ -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>
+89
View File
@@ -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>
+143
View File
@@ -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>
+4 -6
View File
@@ -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) {
-3
View File
@@ -58,9 +58,6 @@
},
"tokens": []
},
"seed": {
"url": "https://sprout.radicle.xyz"
},
"abi": {
"registrar": [
"function rad() view returns (address)",
+6 -4
View File
@@ -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;
}
+140
View File
@@ -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();
}