Organise files more consistently

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2022-12-22 16:46:08 +01:00
parent e86accafae
commit caa83db480
130 changed files with 720 additions and 707 deletions
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import { formatEther } from "@ethersproject/units";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import TextInput from "@app/components/TextInput.svelte";
import {
calculateTimeLock,
getMaxWithdrawAmount,
lastWithdrawalByUser,
} from "@app/lib/faucet";
import { session } from "@app/lib/session";
import { setOpenGraphMetaTag, toWei, capitalize } from "@app/lib/utils";
export let wallet: Wallet;
let amount: string = "";
let loading: boolean = false;
let validationMessage: string | undefined = undefined;
let valid: boolean = false;
setOpenGraphMetaTag([
{ prop: "og:title", content: "Radicle Faucet" },
{ prop: "og:description", content: "Goerli Testnet Faucet" },
{ prop: "og:url", content: window.location.href },
]);
async function withdraw(amount: string) {
if (!valid || !$session) {
return;
}
loading = true;
try {
const currentTime = new Date().getTime();
const timelock = await calculateTimeLock(amount, $session.signer, wallet);
const lastWithdrawal = await lastWithdrawalByUser(
$session.signer,
wallet,
);
const maxWithdrawAmount = await getMaxWithdrawAmount(
$session.signer,
wallet,
);
if (toWei(amount).gt(maxWithdrawAmount)) {
validationMessage = `Reduce amount, max withdrawal is ${formatEther(
maxWithdrawAmount,
)}.`;
return;
}
// Converting a 10 digit to 13 digit timestamp by multiplying by 1000
// since JS doesn't display a correct Date string when passing a 10 digit
// timestamp.
const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
if (nextAvailableWithdraw.gt(currentTime)) {
validationMessage = `Not ready to withdraw, return after ${new Date(
nextAvailableWithdraw.toNumber(),
).toLocaleString("en-GB")}`;
return;
}
router.push({
resource: "faucet",
params: { view: { resource: "withdraw", params: { amount } } },
});
} catch (error) {
validationMessage = "There was an error, check the dev console.";
console.error(error);
} finally {
loading = false;
}
}
function validate(amount: string) {
if (amount === "") {
return { valid: false };
}
if (isNaN(Number(amount)) || Number(amount) <= 0) {
return {
valid: false,
validationMessage: "Please enter a positive number.",
};
}
return { valid: true };
}
$: ({ valid, validationMessage } = validate(amount));
</script>
<style>
main {
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 28rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.subtitle {
color: var(--color-secondary);
}
.form {
display: flex;
gap: 1rem;
}
</style>
<svelte:head>
<title>Radicle &ndash; Faucet</title>
</svelte:head>
<main>
<div class="title">
Obtain RAD tokens on <span class="txt-bold">
{capitalize(wallet.network.name)}
</span>
</div>
{#if wallet.network.name === "homestead"}
<div class="subtitle">
To get RAD tokens on <span class="txt-bold">
{capitalize(wallet.network.name)},
</span>
please
<br />
check
<a
href="https://docs.radicle.xyz/get-involved/obtain-rad"
class="txt-link">
popular exchanges
</a>
&#8203;.
</div>
{:else if !$session}
<div class="subtitle">
To get RAD tokens on <span class="txt-bold">
{capitalize(wallet.network.name)}
</span>
&#8203;,
<br />
please connect your wallet.
</div>
{:else}
<div class="form">
<TextInput
autofocus
placeholder="Enter amount to withdraw"
{validationMessage}
on:submit={() => {
withdraw(amount);
}}
bind:value={amount}
{valid}
{loading} />
<Button
variant="primary"
on:click={() => withdraw(amount)}
disabled={!valid || loading}>
Withdraw
</Button>
</div>
{/if}
</main>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { FaucetRoute } from "@app/lib/router/definitions";
import Form from "@app/views/faucet/Form.svelte";
import Withdraw from "@app/views/faucet/Withdraw.svelte";
export let activeRoute: FaucetRoute;
export let wallet: Wallet;
</script>
{#if activeRoute.params.view.resource === "form"}
<Form {wallet} />
{:else if activeRoute.params.view.resource === "withdraw"}
<Withdraw {wallet} amount={activeRoute.params.view.params.amount} />
{/if}
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import type { State } from "@app/lib/utils";
import type { Wallet } from "@app/lib/wallet";
import { onMount } from "svelte";
import { twemoji } from "@app/lib/utils";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Loading from "@app/components/Loading.svelte";
import Modal from "@app/components/Modal.svelte";
import { Status } from "@app/lib/utils";
import { session } from "@app/lib/session";
import { withdraw } from "@app/lib/faucet";
export let wallet: Wallet;
export let amount: string | null;
let error: Error;
let state: State = {
status: Status.Failed,
error: "Error withdrawing, something happened.",
};
$: requester = $session && $session.address;
function back() {
router.push({ resource: "faucet", params: { view: { resource: "form" } } });
}
onMount(async () => {
try {
if (!amount) {
throw new Error("You must supply the withdrawable amount.");
}
if ($session) {
state.status = Status.Signing;
const tx = await withdraw(amount, $session.signer, wallet);
state.status = Status.Pending;
await tx.wait();
state.status = Status.Success;
} else {
back();
}
} catch (e: any) {
console.error(e);
error = e;
state = { status: Status.Failed, error: e.message };
}
});
</script>
<style>
.loader {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
{#if error}
<ErrorModal
title="Transaction failed"
message={error.message}
on:close={back} />
{:else}
<Modal>
<span slot="title" use:twemoji>
{#if state.status === Status.Success}
<div>🎉</div>
{:else}
<div>🌐</div>
{/if}
Withdrawal
</span>
<span slot="subtitle">
{#if state.status === Status.Signing}
Signing transaction. Please confirm in your wallet.
{:else if state.status === Status.Pending}
Awaiting transaction.
{/if}
</span>
<span slot="body" class="loader">
{#if state.status === Status.Success}
The amount of {amount} RAD tokens has been successfully transfered to
<span class="txt-highlight">{requester}</span>
{:else}
<Loading small center />
{/if}
</span>
<span slot="actions">
{#if state.status === Status.Success}
<Button variant="foreground" on:click={back}>Back</Button>
{/if}
</span>
</Modal>
{/if}
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import type { Host } from "@app/lib/api";
import type { ProjectInfo } from "@app/lib/project";
import * as router from "@app/lib/router";
import Loading from "@app/components/Loading.svelte";
import Message from "@app/components/Message.svelte";
import Widget from "@app/views/projects/Widget.svelte";
import { config } from "@app/lib/config";
import { Project } from "@app/lib/project";
import { setOpenGraphMetaTag, twemoji } from "@app/lib/utils";
setOpenGraphMetaTag([
{ prop: "og:title", content: "Radicle Interface" },
{ prop: "og:description", content: "Interact with Radicle" },
{ prop: "og:url", content: window.location.href },
]);
const getProjects =
config.projects.pinned.length > 0
? Project.getMulti(
config.projects.pinned.map(project => ({
nameOrUrn: project.urn,
seed: project.seed,
})),
)
: Promise.resolve([]);
function onClick(project: ProjectInfo, seed: Host) {
router.push({
resource: "projects",
params: {
view: { resource: "tree" },
urn: project.urn,
peer: undefined,
seed: seed.host,
profile: undefined,
revision: project.head ?? undefined,
},
});
}
</script>
<style>
main {
padding: 3rem 3rem;
width: 100%;
max-width: 74rem;
}
.blurb {
color: var(--color-foreground);
padding: 0rem;
max-width: 70%;
font-size: var(--font-size-medium);
text-align: left;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.projects {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
}
.project {
width: 16rem;
}
.heading {
color: var(--color-secondary);
padding: 1rem 0rem;
font-size: var(--font-size-medium);
margin-bottom: 1rem;
}
.loading {
padding-top: 2rem;
}
@media (max-width: 720px) {
.blurb {
max-width: none;
font-size: var(--font-size-regular);
}
.heading {
font-size: var(--font-size-regular);
}
}
</style>
<svelte:head>
<title>Radicle &ndash; Home</title>
</svelte:head>
<main>
<div class="blurb">
<p use:twemoji>
Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
over a peer-to-peer network 🌐 built on Git.
</p>
</div>
{#await getProjects}
<div class="loading">
<Loading center />
</div>
{:then results}
{#if results.length}
<div class="heading">
Explore <span class="txt-bold">projects</span>
on the Radicle network.
</div>
<div class="projects">
{#each results as result}
<div class="project">
<Widget
compact
project={result.info}
seed={{ api: result.seed }}
on:click={() => onClick(result.info, result.seed)} />
</div>
{/each}
</div>
{/if}
{:catch}
<div class="padding">
<Message error>
<span class="txt-bold">Error:</span>
failed to load projects.
</Message>
</div>
{/await}
</main>
+293
View File
@@ -0,0 +1,293 @@
<script lang="ts">
import type { SvelteComponent } from "svelte";
import type { Wallet } from "@app/lib/wallet";
import type { Seed, Stats } from "@app/lib/seed";
import type { ProjectInfo } from "@app/lib/project";
import * as utils from "@app/lib/utils";
import Address from "@app/components/Address.svelte";
import Async from "@app/components/Async.svelte";
import Avatar from "@app/components/Avatar.svelte";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Icon from "@app/components/Icon.svelte";
import Link from "@app/components/Link.svelte";
import Loading from "@app/components/Loading.svelte";
import NotFound from "@app/components/NotFound.svelte";
import Projects from "@app/views/seeds/View/Projects.svelte";
import RadicleUrn from "@app/components/RadicleUrn.svelte";
import SeedAddress from "@app/components/SeedAddress.svelte";
import SetName from "./SetName.svelte";
import { MissingReverseRecord, NotFoundError } from "@app/lib/error";
import { User, Profile, ProfileType } from "@app/lib/profile";
import { defaultLinkPort } from "@app/lib/seed";
import { session } from "@app/lib/session";
export let wallet: Wallet;
export let addressOrName: string;
export let action: string | null = null;
let setNameForm: typeof SvelteComponent | null =
action === "setName" ? SetName : null;
const setName = () => {
setNameForm = SetName;
};
const getProjectsAndStats = async (
seed: Seed,
id?: string,
): Promise<{
stats: Stats;
projects: ProjectInfo[];
}> => {
const stats = await seed.getStats();
const projects = await seed.getProjects(10, id);
return { stats, projects };
};
$: isUserAuthorized = (address: string): boolean | null => {
return $session && utils.isAddressEqual(address, $session.address);
};
</script>
<style>
main {
padding: 5rem 0;
width: 720px;
}
main > header {
display: flex;
align-items: center;
justify-content: left;
margin-bottom: 2rem;
}
main > header > * {
margin: 0 1rem 0 0;
}
.info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: left;
}
.info a {
border: none;
}
.fields {
display: grid;
grid-template-columns: 5rem 4fr 2fr;
gap: 1rem 2rem;
margin-bottom: 1rem;
}
.fields > div {
place-self: center start;
height: 2rem;
line-height: 2rem;
}
.avatar {
width: 64px;
height: 64px;
}
.title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.links {
display: flex;
align-items: center;
justify-content: left;
}
.overflow-text {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.url {
display: flex; /* Ensures correct vertical positioning of icons */
margin-right: 0.5rem;
height: 1.6rem;
align-items: center;
}
@media (max-width: 720px) {
main {
width: 100%;
padding: 1.5rem;
}
.fields {
grid-template-columns: 5rem auto;
}
}
</style>
<svelte:head>
<title>{addressOrName}</title>
</svelte:head>
{#await Profile.get(addressOrName, ProfileType.Full, wallet)}
<div class="layout-centered">
<Loading center />
</div>
{:then profile}
<main>
<header>
<div class="avatar">
<Avatar
source={profile.avatar ?? profile.address}
title={profile.address} />
</div>
<div class="info">
<span class="title txt-title">
<span class="txt-bold layout-desktop">
{profile.name
? utils.formatName(profile.name, wallet)
: profile.address}
</span>
<span class="txt-bold layout-mobile">
{profile.name
? utils.formatName(profile.name, wallet)
: utils.formatAddress(profile.address)}
</span>
{#if profile.name && profile.org}
<Badge variant="foreground">org</Badge>
{/if}
</span>
<div class="links">
{#if profile.url}
<a class="url" href={profile.url}>
<span class="layout-mobile">
<Icon name="url" />
</span>
<span class="layout-desktop" style="margin-right: 0.3rem;">
{profile.url}
</span>
</a>
{/if}
{#if profile.twitter}
<a class="url" href="https://twitter.com/{profile.twitter}">
<Icon name="twitter" />
</a>
{/if}
{#if profile.github}
<a class="url" href="https://github.com/{profile.github}">
<Icon name="github" />
</a>
{/if}
</div>
</div>
</header>
<div class="fields">
<!-- ID -->
{#if profile.id}
<div class="txt-highlight">ID</div>
<RadicleUrn urn={profile.id} />
{/if}
<!-- Seed Address -->
{#if profile.seed && profile.seed.valid}
<div class="txt-highlight">Seed</div>
<SeedAddress seed={profile.seed} port={defaultLinkPort} />
{/if}
<!-- Address -->
<div class="txt-highlight">Address</div>
<div class="layout-desktop">
<Address {wallet} {profile} address={profile.address} />
</div>
<div class="layout-mobile">
<Address compact {wallet} {profile} address={profile.address} />
</div>
<div class="layout-desktop" />
<!-- Owner -->
{#if profile.org}
<div class="txt-highlight">Owner</div>
<div class="layout-desktop">
<Address resolve {wallet} address={profile.org.owner} />
</div>
<div class="layout-mobile">
<Address compact resolve {wallet} address={profile.org.owner} />
</div>
<div class="layout-desktop" />
{/if}
<!-- Org Name/Profile -->
<div class="txt-highlight">Profile</div>
{#if profile.org}
{#if utils.isAddressEqual(profile.address, profile.org.address)}
<div class="overflow-text">
{#if profile.name && profile.ens}
<Link
route={{
resource: "registrations",
params: {
view: {
resource: "view",
params: { nameOrDomain: profile.ens.name, retry: false },
},
},
}}>
<span class="txt-link">{profile.name}</span>
</Link>
{:else}
<span class="txt-missing">Not set</span>
{/if}
</div>
{/if}
{:else}
<!-- User Profile -->
<div>
{#if profile.name && profile.ens}
<Link
route={{
resource: "registrations",
params: {
view: {
resource: "view",
params: { nameOrDomain: profile.ens.name, retry: false },
},
},
}}>
<span class="txt-link">{profile.name}</span>
</Link>
{:else}
<span class="txt-missing">Not set</span>
{/if}
</div>
<div class="layout-desktop">
{#if isUserAuthorized(profile.address) && !profile.org}
<Button variant="secondary" size="small" on:click={setName}>
Set
</Button>
{/if}
</div>
{/if}
</div>
{#if profile.seed?.valid}
<Async fetch={getProjectsAndStats(profile.seed, profile.id)} let:result>
<Projects
{profile}
seed={profile.seed}
stats={result.stats}
projects={result.projects} />
</Async>
{/if}
</main>
<svelte:component
this={setNameForm}
entity={new User(profile.address)}
{wallet}
on:close={() => (setNameForm = null)} />
{:catch err}
{#if err instanceof NotFoundError}
<NotFound
title={addressOrName}
subtitle="Sorry, the requested address or domain was not found." />
{:else if err instanceof MissingReverseRecord}
<NotFound
title={addressOrName}
subtitle="Sorry, the requested name has no reverse record set." />
{:else}
<ErrorModal error={err} />
{/if}
{/await}
+185
View File
@@ -0,0 +1,185 @@
<script lang="ts" strictEvents>
import type { Wallet } from "@app/lib/wallet";
import type { User } from "@app/lib/profile";
import { createEventDispatcher } from "svelte";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Loading from "@app/components/Loading.svelte";
import Modal from "@app/components/Modal.svelte";
import TextInput from "@app/components/TextInput.svelte";
import { formatAddress, isAddressEqual, twemoji } from "@app/lib/utils";
const dispatch = createEventDispatcher<{ close: never }>();
export let entity: User;
export let wallet: Wallet;
enum State {
Idle,
Checking,
Signing,
Pending,
Success,
Failed,
Mismatch,
}
let name = "";
let state = State.Idle;
let error: string | null = null;
const onSubmit = async () => {
if (!valid) {
return;
}
state = State.Checking;
const domain = `${name}.${wallet.registrar.domain}`;
const resolved = await wallet.provider.resolveName(domain);
if (resolved && isAddressEqual(resolved, entity.address)) {
try {
state = State.Signing;
const tx = await entity.setName(domain, wallet);
state = State.Pending;
await tx.wait();
state = State.Success;
} catch (e) {
console.error(e);
state = State.Failed;
if (e instanceof Error) {
error = e.message;
} else {
error = "Unknown error. Check dev console for details.";
}
}
} else {
state = State.Mismatch;
}
};
$: valid = name !== "" && state === State.Idle;
</script>
<style>
.actions {
display: flex;
justify-content: center;
gap: 1rem;
}
</style>
{#if state === State.Success}
<Modal floating>
<div slot="title" use:twemoji>✅</div>
<div slot="subtitle">
The ENS name for {entity.address} was set to
<span class="txt-bold">{name}.{wallet.registrar.domain}</span>
.
</div>
<div slot="actions">
<Button variant="secondary" on:click={() => dispatch("close")}>
Done
</Button>
</div>
</Modal>
{:else if state === State.Mismatch}
<ErrorModal floating title="🧣" on:close>
The name <span class="txt-bold">{name}.{wallet.registrar.domain}</span>
does not resolve to
<span class="txt-bold">{entity.address}</span>
. Please update the ENS record for {name}.{wallet.registrar.domain} to to the
correct address and try again.
<div slot="actions">
<Button
variant="negative"
on:click={() =>
router.push({
resource: "registrations",
params: {
view: {
resource: "view",
params: { nameOrDomain: name, retry: false },
},
},
})}>
Go to registration &rarr;
</Button>
<Button variant="negative" on:click={() => dispatch("close")}>
Close
</Button>
</div>
</ErrorModal>
{:else if state === State.Failed && error}
<ErrorModal floating title="Transaction failed" message={error} on:close />
{:else}
<Modal floating>
<div slot="title">
<div use:twemoji>🧣</div>
<span>Associate profile</span>
</div>
<div slot="subtitle">
{#if state === State.Signing}
Please confirm the transaction in your wallet.
{:else if state === State.Pending}
Waiting for transaction to be processed…
{:else}
Set an ENS name for <span class="txt-bold">
{formatAddress(entity.address)}
</span>
to associate a profile. ENS profiles provide human-identifiable data to your
profile, such as a unique name, avatar and URL, and help make your profile
more discoverable.
{/if}
</div>
<div slot="body" style="display: flex; justify-content:center;">
{#if state === State.Idle || state === State.Checking}
<div style="width: 22rem;">
<TextInput
autofocus
disabled={state !== State.Idle}
on:submit={onSubmit}
loading={state === State.Checking}
{valid}
bind:value={name}>
<svelte:fragment slot="right">
.{wallet.registrar.domain}
</svelte:fragment>
</TextInput>
</div>
{:else}
<Loading small center />
{/if}
</div>
<div slot="actions" class="actions">
{#if state === State.Signing}
<Button variant="secondary" on:click={() => dispatch("close")}>
Cancel
</Button>
{:else if state === State.Pending}
<Button variant="secondary" on:click={() => dispatch("close")}>
Close
</Button>
{:else}
<Button variant="primary" on:click={onSubmit} disabled={!valid}>
Submit
</Button>
<Button variant="text" on:click={() => dispatch("close")}>
Cancel
</Button>
{/if}
</div>
</Modal>
{/if}
+250
View File
@@ -0,0 +1,250 @@
<script lang="ts">
import type { MaybeBlob, Blob } from "@app/lib/project";
import type { MaybeHighlighted } from "@app/lib/syntax";
import type { ProjectRoute } from "@app/lib/router/definitions";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
import Readme from "@app/views/projects/Readme.svelte";
import { afterUpdate, beforeUpdate, onMount } from "svelte";
import { highlight } from "@app/lib/syntax";
import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
import { lineNumbersGutter } from "@app/lib/syntax";
import { toHtml } from "hast-util-to-html";
import { updateProjectRoute } from "@app/lib/router";
export let activeRoute: ProjectRoute;
export let blob: Blob;
export let getImage: (path: string) => Promise<MaybeBlob>;
export let line: string | undefined = undefined;
const fileExtension = blob.path.split(".").pop() ?? "";
const lastCommit = blob.info.lastCommit;
const parentDir = blob.path
.match(/^.*\/|/)
?.values()
.next().value;
let content: MaybeHighlighted = undefined;
// Any time a user clicks on a line number, the `line` prop gets updated,
// and the line is highlighted, but the previous line is not unhighlighted.
// So we have to make sure here that any previous highlighting gets removed,
// before updating the component.
beforeUpdate(() => {
for (const item of document.getElementsByClassName("highlight")) {
item.classList.remove("highlight");
}
});
onMount(async () => {
const output = await highlight(blob.content, fileExtension);
if (output) {
content = lineNumbersGutter(output);
}
});
afterUpdate(() => {
if (line) {
scrollIntoView(line);
const element = document.getElementById(line);
if (element) {
element.classList.add("highlight");
}
}
});
const isMarkdown = isMarkdownPath(blob.path);
// If we have a line number we should show the raw output.
let showMarkdown = line ? false : isMarkdown;
const toggleMarkdown = () => {
updateProjectRoute({ line: undefined });
showMarkdown = !showMarkdown;
};
</script>
<style>
header .file-header {
display: flex;
height: 3rem;
align-items: center;
justify-content: space-between;
padding: 0 0.5rem 0 1rem;
color: var(--color-foreground);
border-width: 1px 1px 0 1px;
border-color: var(--color-foreground-3);
border-style: solid;
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.file-header .right {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
overflow-x: hidden;
text-overflow: ellipsis;
width: 100%;
}
header .file-name {
font-weight: var(--font-weight-normal);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 1rem;
}
.last-commit {
padding: 0.5rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
font-size: var(--font-size-tiny);
border-radius: var(--border-radius-small);
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-commit .hash {
font-weight: var(--font-weight-bold);
font-family: var(--font-family-monospace);
margin-right: 0.25rem;
}
.markdown-toggle {
margin-right: 0.5rem;
}
.code :global(.line-number) {
color: var(--color-foreground-4);
text-align: right;
padding-right: 1rem;
padding-left: 1rem;
}
.code :global(.line-number:hover) {
cursor: pointer;
color: var(--color-foreground);
}
.code :global(.content) {
display: inline;
font-family: var(--font-family-monospace);
margin: 0;
}
.code :global(.line) {
line-height: 22px; /* This seems to be the line-height of a pre code block */
}
.code :global(.highlight) {
background-color: var(--color-caution-3);
}
.code :global(.highlight td a) {
color: var(--color-foreground);
}
.code :global(.line-content) {
padding: 0;
width: 100%;
}
.code {
width: 100%;
border-spacing: 0;
overflow-x: auto;
font-size: 1rem;
padding-top: 1rem;
margin-bottom: 1.5rem;
}
.container {
position: relative;
display: flex;
overflow-x: auto;
border: 1px solid var(--color-foreground-3);
border-top-style: dashed;
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
.binary {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 16rem;
background-color: var(--color-foreground-1);
color: var(--color-foreground-6);
font-family: var(--font-family-monospace);
}
.binary > * {
margin-bottom: 1rem;
}
.no-scrollbar {
scrollbar-width: none;
}
.markdown {
max-width: 64rem;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@media (max-width: 960px) {
.code {
font-size: var(--font-size-small);
}
}
@media (max-width: 720px) {
.right {
justify-content: center;
}
}
</style>
<div class:markdown={isMarkdown}>
<header>
<div class="file-header">
<span class="file-name">
<span class="txt-faded">{parentDir}</span>
&#8203;
<span>{blob.info.name}</span>
</span>
<div class="right">
{#if isMarkdown}
<div class="markdown-toggle">
<HeaderToggleLabel
active={!showMarkdown}
clickable
on:click={toggleMarkdown}>
Raw
</HeaderToggleLabel>
</div>
{/if}
<div class="last-commit" title={lastCommit.author.name} use:twemoji>
<span class="hash">{lastCommit.sha1.slice(0, 7)}</span>
{lastCommit.summary}
</div>
</div>
</div>
</header>
<div class="container">
{#if blob.binary}
<div class="binary">
<div use:twemoji>👀</div>
<span class="txt-tiny">Binary content</span>
</div>
{:else if showMarkdown}
<Readme content={blob.content} {getImage} {activeRoute} />
{:else if content}
<table class="code no-scrollbar">
{@html toHtml(content)}
</table>
{/if}
</div>
</div>
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import type { ProjectInfo, Branches } from "@app/lib/project";
import { getOid } from "@app/lib/project";
import { formatCommit } from "@app/lib/utils";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating from "@app/components/Floating.svelte";
export let branches: Branches;
export let project: ProjectInfo;
export let revision: string;
const dispatch = createEventDispatcher<{ branchChanged: string }>();
const switchBranch = (name: string) => {
dispatch("branchChanged", name);
};
let branchLabel: string | null = null;
$: branchList = Object.keys(branches)
.sort()
.map(b => ({ key: b, value: b, title: `Switch to ${b}`, badge: null }));
$: showSelector = branchList.length > 1;
$: head = project.head ?? branches[project.defaultBranch];
$: commit = getOid(revision, branches) || head;
$: if (commit === head) {
branchLabel = project.defaultBranch;
} else if (branches[revision]) {
branchLabel = revision;
} else {
branchLabel = null;
}
</script>
<style>
.commit {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-monospace);
}
.commit .branch {
padding: 0.5rem 0.75rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
}
.commit .branch.not-allowed {
cursor: not-allowed;
}
.branch:hover:not(.not-allowed) {
background-color: var(--color-foreground-2);
}
.commit .hash {
display: inline-block;
height: 2rem;
line-height: initial;
color: var(--color-secondary);
background-color: var(--color-secondary-1);
padding: 0.5rem 0.75rem;
border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
}
.commit .hash.unlabeled {
border-radius: var(--border-radius-small);
}
.stat {
font-family: var(--font-family-monospace);
padding: 0.5rem 0.75rem;
height: 2rem;
line-height: initial;
background: var(--color-foreground-1);
}
</style>
<div class="commit" title="Current branch">
<!-- Check for branches listing feature -->
{#if branchList.length > 0}
{#if branchLabel}
<Floating disabled={!showSelector}>
<div
slot="toggle"
title="Change branch"
class="stat branch"
class:not-allowed={!showSelector}>
{branchLabel}
</div>
<svelte:fragment slot="modal">
<Dropdown
items={branchList}
selected={branchLabel}
on:select={e => switchBranch(e.detail)} />
</svelte:fragment>
</Floating>
<div class="hash layout-desktop">
{formatCommit(commit)}
</div>
{:else}
<div class="unlabeled hash layout-desktop">
{commit}
</div>
{/if}
<div class="hash layout-mobile">
{formatCommit(commit)}
</div>
<!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
{:else if commit === head}
<div class="stat branch not-allowed">
{project.defaultBranch}
</div>
<div class="hash">
{formatCommit(commit)}
</div>
{:else}
<div class="unlabeled hash layout-desktop">
{commit}
</div>
<div class="hash layout-mobile">
{formatCommit(commit)}
</div>
{/if}
</div>
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts" context="module">
import { writable } from "svelte/store";
export const browserErrorStore = writable<
{ message: string; path: string } | undefined
>();
</script>
<script lang="ts">
import type * as proj from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Loading from "@app/components/Loading.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import { onMount } from "svelte";
import Tree from "./Tree.svelte";
import Blob from "./Blob.svelte";
enum Status {
Loading,
Loaded,
}
type State =
| { status: Status.Loading; path: string }
| { status: Status.Loaded; path: string; blob: proj.Blob };
export let project: proj.Project;
export let tree: proj.Tree;
export let commit: string;
export let activeRoute: ProjectRoute;
$: path = activeRoute.params.path || "/";
$: line = activeRoute.params.line;
// When the component is loaded the first time, the blob is yet to be loaded.
let state: State = { status: Status.Loading, path };
// Whether the mobile file tree is visible.
let mobileFileTree = false;
const loadBlob = async (path: string) => {
if (state.status === Status.Loaded && state.path === path) {
return state.blob;
}
const promise =
path === "/" ? project.getReadme(commit) : project.getBlob(commit, path);
state = { status: Status.Loading, path };
state = { status: Status.Loaded, path, blob: await promise };
return state.blob;
};
onMount(() => {
browserErrorStore.set(undefined);
});
// Get an image blob based on a relative path.
const getImage = async (imagePath: string) => {
const finalPath = utils.canonicalize(imagePath, path);
return project.getBlob(commit, finalPath).catch(() => {
console.warn("Not able to load image blob:", finalPath);
return undefined;
});
};
const onSelect = async (newPath: string) => {
browserErrorStore.set(undefined);
// Ensure we don't spend any time in a "loading" state. This means
// the loading spinner won't be shown, and instead the blob will be
// displayed once loaded.
const blob = await loadBlob(newPath).catch(() => {
browserErrorStore.set({
message: "Not able to load selected file",
path: newPath,
});
return undefined;
});
if (blob) {
getBlob = new Promise(resolve => resolve(blob));
}
// Close mobile tree if user navigates to other file
mobileFileTree = false;
router.updateProjectRoute({
view: { resource: "tree" },
path: newPath,
});
};
const fetchTree = async (path: string) => {
return project.getTree(commit, path).catch(() => {
browserErrorStore.set({
message: "Not able to expand directory",
path,
});
return undefined;
});
};
const toggleMobileFileTree = () => {
mobileFileTree = !mobileFileTree;
};
$: getBlob = loadBlob(path).catch(() => {
browserErrorStore.set({ message: "Not able to load file", path });
return undefined;
});
$: loadingPath =
!$browserErrorStore && state.status === Status.Loading ? state.path : null;
</script>
<style>
.center-content {
margin: 0 auto;
}
.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%;
}
.placeholder {
display: flex;
flex-direction: column;
width: 100%;
}
.source-tree {
overflow-x: hidden;
}
nav {
padding: 0 2rem;
}
.sticky {
position: sticky;
top: 2rem;
max-height: 100vh;
}
@media (max-width: 960px) {
.container {
padding-left: 2rem;
}
}
@media (max-width: 720px) {
.column-right {
padding: 1.5rem 0;
min-width: 0;
}
.placeholder {
padding: 1.5rem;
}
.source-tree {
padding: 0 2rem;
margin: 1rem 0;
}
.container {
padding: 0;
flex-direction: column;
}
.column-left {
display: none;
padding-right: 0;
}
.column-left-visible {
display: block;
}
.sticky {
max-height: initial;
}
}
</style>
<main>
<!-- Mobile navigation -->
{#if tree.entries.length > 0}
<nav class="layout-mobile">
<Button
style="width: 100%;"
variant="secondary"
on:click={toggleMobileFileTree}>
Browse
</Button>
</nav>
{/if}
<div class="container center-content">
{#if tree.entries.length > 0}
<div class="column-left" class:column-left-visible={mobileFileTree}>
<div class="source-tree sticky">
<Tree
{tree}
{path}
{fetchTree}
{loadingPath}
on:select={e => {
onSelect(e.detail);
}} />
</div>
</div>
<div class="column-right">
{#if $browserErrorStore}
<Placeholder emoji="🍂">
<span slot="title">
<div class="txt-monospace">{$browserErrorStore.path}</div>
</span>
<span slot="body">
<span>
{#if $browserErrorStore.path === "/"}
The README could not be loaded.
{:else}
{$browserErrorStore.message}
{/if}
</span>
</span>
</Placeholder>
{:else}
{#await getBlob}
<Loading small center />
{:then blob}
{#if blob}
<Blob {line} {blob} {getImage} {activeRoute} />
{/if}
{/await}
{/if}
</div>
{:else}
<div class="placeholder">
<Placeholder emoji="👀">
<span slot="title">Nothing to show</span>
<span slot="body">We couldn't find any files at this revision.</span>
</Placeholder>
</div>
{/if}
</div>
</main>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import * as utils from "@app/lib/utils";
import Clipboard from "@app/components/Clipboard.svelte";
import Floating from "@app/components/Floating.svelte";
import { closeFocused } from "@app/components/Floating.svelte";
export let seedHost: string;
export let urn: string;
$: radCloneUrl = `rad clone rad://${seedHost}/${utils.parseRadicleId(urn)}`;
$: gitCloneUrl = `https://${seedHost}/${utils.parseRadicleId(urn)}.git`;
</script>
<style>
.clone-button {
background-color: var(--color-caution-2);
border-radius: var(--border-radius-small);
color: var(--color-caution-6);
font-family: var(--font-family-monospace);
min-width: max-content;
height: 2rem;
line-height: initial;
padding: 0.5rem 0.75rem;
}
.clone-button:hover {
background-color: var(--color-caution-3);
}
.dropdown {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
box-shadow: var(--elevation-low);
margin-top: 0.5rem;
padding: 1rem;
position: absolute;
width: 24rem;
z-index: 10;
}
@media (max-width: 720px) {
.dropdown {
width: auto;
left: 2rem;
right: 2rem;
z-index: 10;
}
}
label {
color: var(--color-foreground-6);
display: block;
font-size: var(--font-size-tiny);
padding: 0.5rem 0.5rem 0 0.25rem;
}
.clone-url-wrapper {
position: relative;
display: flex;
align-items: center;
}
.clone-url {
border-radius: var(--border-radius-small);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
height: 2rem;
overflow: hidden;
padding: 0.5rem;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
color: var(--color-caution-6);
background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
linear-gradient(var(--color-background), var(--color-background));
}
.clipboard {
position: absolute;
right: 0;
color: var(--color-caution-6);
background: linear-gradient(var(--color-caution-2), var(--color-caution-2)),
linear-gradient(var(--color-background), var(--color-background));
visibility: hidden;
width: 4rem;
height: 2rem;
text-align: right;
-webkit-mask: linear-gradient(90deg, transparent 0%, #fff 50%);
mask: linear-gradient(90deg, transparent 0%, #fff 50%);
border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
}
.clone-url-wrapper:hover .clipboard {
visibility: visible;
}
</style>
<Floating>
<div slot="toggle" class="clone-button">Clone</div>
<svelte:fragment slot="modal">
<div class="dropdown">
<div class="clone-url-wrapper">
<div class="clone-url" name="rad-clone-url">{radCloneUrl}</div>
<span class="clipboard">
<Clipboard text={radCloneUrl} on:copied={closeFocused} />
</span>
</div>
<label for="rad-clone-url">
Use the <a
target="_blank"
rel="noreferrer"
href="https://radicle.xyz/get-started.html"
class="link">
Radicle CLI
</a>
to clone this project.
</label>
<br />
<div class="clone-url-wrapper">
<div class="clone-url" name="git-clone-url">{gitCloneUrl}</div>
<span class="clipboard">
<Clipboard text={gitCloneUrl} on:copied={closeFocused} />
</span>
</div>
<label for="git-clone-url">
Use Git to clone this repository from the URL above.
</label>
</div>
</svelte:fragment>
</Floating>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Commit } from "@app/lib/commit";
import { formatCommit, twemoji } from "@app/lib/utils";
import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
import CommitVerifiedBadge from "@app/views/projects/Commit/CommitVerifiedBadge.svelte";
import * as router from "@app/lib/router";
export let commit: Commit;
const onBrowse = (event: { detail: string }) => {
router.updateProjectRoute({
view: { resource: "tree" },
path: event.detail,
});
};
</script>
<style>
.commit {
padding: 0 2rem 0 8rem;
}
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.summary {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.description {
margin: 0.5rem 0 1rem 0;
}
.sha1 {
color: var(--color-foreground-5);
font-size: var(--font-size-small);
}
.authorship {
display: flex;
align-items: center;
justify-content: space-between;
}
@media (max-width: 960px) {
.commit {
padding-left: 2rem;
}
}
</style>
<div class="commit">
<header>
<div class="summary">
<div class="txt-medium" use:twemoji>{commit.header.summary}</div>
<div class="layout-desktop txt-monospace sha1">
<span>{commit.header.sha1}</span>
</div>
<div class="layout-mobile txt-monospace sha1 txt-small">
{formatCommit(commit.header.sha1)}
</div>
</div>
<pre class="description txt-small">{commit.header.description}</pre>
<div class="authorship">
<CommitAuthorship {commit} />
{#if commit.context?.committer}
<CommitVerifiedBadge {commit} />
{/if}
</div>
</header>
<Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import type { CommitMetadata } from "@app/lib/commit";
import Badge from "@app/components/Badge.svelte";
import { formatTimestamp, gravatarURL } from "@app/lib/utils";
export let commit: CommitMetadata;
export let noTime = false;
export let noAuthor = false;
export let noDelegate = false;
</script>
<style>
.authorship {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--color-foreground-5);
padding: 0.125rem 0;
}
.authorship .author,
.authorship .committer {
color: var(--color-foreground);
white-space: nowrap;
}
.authorship .avatar {
width: 1rem;
height: 1rem;
border-radius: var(--border-radius);
}
@media (max-width: 720px) {
.authorship {
display: none;
}
}
</style>
<span class="authorship txt-tiny">
{#if commit.header.author.email === commit.header.committer.email}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.committer.email)} />
{#if commit.context?.committer}
<span
class="committer"
class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
{commit.context.committer.peer.person?.name ||
commit.header.committer.name}
</span>
{#if !noDelegate && commit.context?.committer.peer.delegate}
<Badge variant="tertiary">delegate</Badge>
{/if}
<span>committed</span>
{:else}
<span class="layout-desktop-inline committer">
{commit.header.committer.name}
</span>
<span>committed</span>
{/if}
{:else}
{#if !noAuthor}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.author.email)} />
<span class="layout-desktop-inline author">
{commit.header.author.name}
</span>
<span>authored</span>
{/if}
<img
class="avatar"
alt="avatar"
src={gravatarURL(commit.header.committer.email)} />
{#if commit.context?.committer}
<span
class="committer"
class:txt-bold={Boolean(commit.context.committer.peer.person?.name)}>
{commit.context.committer.peer.person?.name ||
commit.header.committer.name}
</span>
{#if !noDelegate && commit.context?.committer.peer.delegate}
<Badge variant="tertiary">delegate</Badge>
{/if}
<span>committed</span>
{:else}
<span class="layout-desktop-inline committer">
{commit.header.committer.name}
</span>
<span>committed</span>
{/if}
{/if}
{#if !noTime}
<span class="layout-desktop-inline">
{formatTimestamp(commit.header.committerTime)}
</span>
{/if}
</span>
@@ -0,0 +1,111 @@
<script lang="ts" strictEvents>
import type { CommitMetadata } from "@app/lib/commit";
import { formatCommit, twemoji } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
import Icon from "@app/components/Icon.svelte";
import CommitAuthorship from "./CommitAuthorship.svelte";
import CommitVerifiedBadge from "./CommitVerifiedBadge.svelte";
export let commit: CommitMetadata;
const dispatch = createEventDispatcher<{ browseCommit: string }>();
function browseCommit(commit: string) {
dispatch("browseCommit", commit);
}
</script>
<style>
.hash {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
padding: 0 1.5rem;
}
.commit-teaser {
background-color: var(--color-foreground-1);
padding: 0.75rem 0rem;
}
.commit-teaser:hover {
background-color: var(--color-foreground-2);
}
.commit-teaser:first-child {
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.commit-teaser:last-child {
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
.commit-teaser {
display: flex;
align-items: center;
justify-content: space-between;
}
.column-left {
padding-left: 1rem;
flex: min-content;
}
.commit-teaser .column-right {
display: flex;
align-items: center;
padding-right: 1.5rem;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
.browse {
display: flex;
z-index: 10;
width: 100%;
height: 100%;
}
@media (max-width: 720px) {
.hash {
padding-right: 0;
}
.column-left {
overflow: hidden;
}
.browse {
display: none !important;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="commit-teaser">
<div class="column-left">
<div class="header">
<div class="summary" use:twemoji>
{commit.header.summary}
</div>
</div>
<CommitAuthorship {commit} noDelegate />
</div>
<div class="column-right">
{#if commit.context.committer}
<div class="layout-desktop">
<CommitVerifiedBadge {commit} />
</div>
{/if}
<span class="hash txt-highlight">{formatCommit(commit.header.sha1)}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="browse"
title="Browse the repository at this point in the history"
on:click|stopPropagation={() => browseCommit(commit.header.sha1)}>
<Icon name="browse" />
</div>
</div>
</div>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { CommitMetadata } from "@app/lib/commit";
import debounce from "lodash/debounce";
import Badge from "@app/components/Badge.svelte";
import CommitAuthorship from "./CommitAuthorship.svelte";
export let commit: CommitMetadata;
let visible = false;
const showDelay = 50; // ms
const setVisible = debounce((value: boolean) => {
visible = value;
}, showDelay);
</script>
<style>
.container {
cursor: default;
}
.wrapper {
position: absolute;
}
.popup {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
box-shadow: var(--elevation-low);
color: var(--color-foreground);
font-size: var(--font-size-tiny);
left: -10rem;
margin-top: 0.5rem;
padding: 0.5rem 0;
position: absolute;
min-width: 14rem;
z-index: 99;
}
.header {
display: flex;
padding: 1rem 0.75rem;
gap: 0.5rem;
}
.highlight {
color: var(--color-tertiary);
}
.committer {
border-top: 1px dashed var(--color-foreground-4);
padding: 0.75rem;
}
.peer {
padding-top: 0.5rem;
word-break: break-all;
color: var(--color-foreground-5);
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="container"
on:click|stopPropagation
on:mouseenter={() => setVisible(true)}
on:mouseleave={() => setVisible(false)}>
<Badge variant="tertiary">Verified</Badge>
{#if visible}
<div class="wrapper">
<div class="popup">
<div class="header">
<div class="highlight">✔</div>
<div>
This commit was <span class="highlight">signed</span>
with the committer's radicle key.
</div>
</div>
<div class="committer">
<CommitAuthorship {commit} noAuthor noTime />
{#if commit.context.committer}
<div class="peer">
{commit.context.committer.peer.id}
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from "svelte";
import type { WeeklyActivity } from "@app/lib/commit";
export let strokeWidth: number;
export let points: WeeklyActivity[];
export let viewBoxWidth: number;
export let viewBoxHeight: number;
// The path strings to be inserted into the svg <path>
let path = "";
let areaPath = "";
const heightWithPadding = viewBoxHeight + 16;
// The latest point on the x axis, starting at 0 until `viewBoxWidth`
let lastWidthPoint = viewBoxWidth;
// The amount of points on the x axis
const widthIteration = viewBoxWidth / 52;
// The highest value on the y axis
const commitCountArray: number[] = [];
// The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight` points of height in the SVG.
const minimalHeight = 5;
let week = 0;
for (const point of points) {
if (point.week - week > 1) {
commitCountArray.push(...new Array(point.week - week).fill(0));
}
commitCountArray.push(point.commits.length);
week = point.week;
}
// Formats the points passed in, into a svg path string, without closing the area
function createPath() {
let i = 1;
if (commitCountArray.length < 52) {
commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
}
const maxValue = Math.max(...commitCountArray);
const minValue = Math.min(...commitCountArray);
// Normalizes the values to the viewBox dimensions
const normalizedArray = commitCountArray.map(c => {
// If we are not crossing the `viewBoxHeight` we want to return the actual value,
// and don't want to normalize <`minimalHeight` commit counts as huge spikes.
if (maxValue < viewBoxHeight && c >= minimalHeight) {
return c;
}
// If the value is 0..minimalHeight though we don't want to set it to the minimalHeight.
else if (c > 0 && c < minimalHeight) {
return minimalHeight;
}
// If the count is 0 we have to make sure the normalization is not being run since it would return NaN
else {
return c === 0
? 0
: ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
}
});
const path = normalizedArray.slice(1).reduce(
(acc, curr) => {
const s = `${viewBoxWidth - widthIteration * i},${
viewBoxHeight - curr
}`;
lastWidthPoint = viewBoxWidth - widthIteration * i;
i += 1;
return acc.concat(s);
},
[`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
);
return path.join();
}
onMount(() => {
// Creates the stroke path with the array of points
path = createPath();
// Concats a path closing for it to be the area under the stroke
areaPath = path.concat(
`L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
);
});
</script>
<svg
viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
xmlns="http://www.w3.org/2000/svg">
<svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
<stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
</linearGradient>
</defs>
</svg>
<svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
<stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
<stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
</linearGradient>
</defs>
</svg>
{#if points.length > 0}
<g>
<path
fill="transparent"
stroke="url(#gradient)"
stroke-width={strokeWidth}
stroke-linejoin="round"
d={path} />
<path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
</g>
{:else}
<!-- If no commits have been made in a year, we show a straight line -->
<line
x1="0"
y1={viewBoxHeight}
x2="600"
y2={viewBoxHeight}
stroke="#ff55ff"
stroke-width={1} />
{/if}
</svg>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import type { Project } from "@app/lib/project";
import type { Tree } from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import * as router from "@app/lib/router";
import BranchSelector from "@app/views/projects/BranchSelector.svelte";
import CloneButton from "@app/views/projects/CloneButton.svelte";
import PeerSelector from "@app/views/projects/PeerSelector.svelte";
import { closeFocused } from "@app/components/Floating.svelte";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
export let activeRoute: ProjectRoute;
export let project: Project;
export let tree: Tree;
export let commit: string;
const { urn, peers, branches, seed } = project;
$: revision = activeRoute.params.revision ?? commit;
// Switches between project views.
const toggleContent = (
input: "patches" | "issues" | "history",
keepSourceInPath: boolean,
) => {
router.updateProjectRoute({
view: {
resource: activeRoute.params.view.resource === input ? "tree" : input,
},
urn: project.urn,
revision: revision,
...(keepSourceInPath ? null : { revision: undefined, path: undefined }),
});
};
const updatePeer = (peer: string) => {
router.updateProjectRoute({
peer,
revision: undefined,
});
closeFocused();
};
const updateRevision = (revision: string) => {
router.updateProjectRoute({
revision,
});
closeFocused();
};
function goToSeed() {
if (seed.api.port) {
router.push({
resource: "seeds",
params: { host: `${seed.api.host}:${seed.api.port}` },
});
} else {
router.push({ resource: "seeds", params: { host: seed.api.host } });
}
}
</script>
<style>
header {
font-size: var(--font-size-tiny);
padding: 0 2rem 0 8rem;
margin-bottom: 2rem;
display: flex;
align-items: center;
justify-content: left;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 960px) {
header {
padding-left: 2rem;
}
header {
margin-bottom: 1.5rem;
}
}
</style>
<header>
{#if peers.length > 0}
<PeerSelector
{peers}
peer={activeRoute.params.peer}
on:peerChanged={event => updatePeer(event.detail)} />
{/if}
<BranchSelector
{branches}
{project}
{revision}
on:branchChanged={event => updateRevision(event.detail)} />
{#if seed.git.host}
<CloneButton seedHost={seed.git.host} {urn} />
{/if}
<span>
{#if seed.api.host}
<HeaderToggleLabel
clickable
ariaLabel="Seed"
title="Project data is fetched from this seed"
on:click={goToSeed}>
<span>{seed.api.host}</span>
</HeaderToggleLabel>
{/if}
</span>
<HeaderToggleLabel
ariaLabel="Commit count"
clickable
active={activeRoute.params.view.resource === "history"}
on:click={() => toggleContent("history", true)}>
<span class="txt-bold">{tree.stats.commits}</span>
commit(s)
</HeaderToggleLabel>
{#if project.issues}
<HeaderToggleLabel
ariaLabel="Issue count"
active={activeRoute.params.view.resource === "issues"}
disabled={project.issues === 0}
clickable={project.issues > 0}
on:click={() => toggleContent("issues", false)}>
<span class="txt-bold">{project.issues}</span>
issue(s)
</HeaderToggleLabel>
{/if}
{#if project.patches}
<HeaderToggleLabel
ariaLabel="Patch count"
clickable={project.patches > 0}
active={activeRoute.params.view.resource === "patches"}
disabled={project.patches === 0}
on:click={() => toggleContent("patches", false)}>
<span class="txt-bold">{project.patches}</span>
patch(es)
</HeaderToggleLabel>
{/if}
<HeaderToggleLabel ariaLabel="Contributor count">
<span class="txt-bold">{tree.stats.contributors}</span>
contributor(s)
</HeaderToggleLabel>
</header>
@@ -0,0 +1,49 @@
<script lang="ts">
export let title: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let active = false;
export let clickable = false;
export let disabled = false;
</script>
<style>
.stat {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
padding: 0.5rem 0.75rem;
padding-bottom: 1rem; /* moving the content a tad higher to match the previous span usage */
height: 2rem;
background: var(--color-foreground-1);
border: none;
color: var(--color-foreground);
border-radius: var(--border-radius-small);
min-width: max-content;
}
.active {
color: var(--color-background);
background: var(--color-foreground) !important;
background-color: var(--color-foreground);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background-color: var(--color-foreground-2);
}
.not-allowed {
cursor: not-allowed;
color: var(--color-foreground-5);
}
</style>
<button
{title}
{disabled}
class:active
class:clickable
class:not-allowed={disabled}
class="stat"
aria-label={ariaLabel}
on:click>
<slot />
</button>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import type { CommitMetadata, CommitsHistory } from "@app/lib/commit";
import CommitTeaser from "./Commit/CommitTeaser.svelte";
import { Project } from "@app/lib/project";
import { groupCommits } from "@app/lib/commit";
import List from "@app/components/List.svelte";
import * as router from "@app/lib/router";
export let project: Project;
export let history: CommitsHistory;
const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
const response = await Project.getCommits(project.urn, project.seed.api, {
// Fetching 31 elements since we remove the first one
parent: history.headers[history.headers.length - 1].header.sha1,
perPage: 31,
verified: true,
});
// Removing the first element of the array, since it's the same as the last of the current list
return response.headers.slice(1);
};
const browseCommit = (event: { detail: string }) => {
router.updateProjectRoute({
view: { resource: "tree" },
revision: event.detail,
});
};
</script>
<style>
.history {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.commit-group header {
color: var(--color-foreground-6);
}
.commit-group-headers {
margin-bottom: 2rem;
}
.commit {
background-color: var(--color-foreground-1);
}
.commit:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
.commit:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.commit:first-child {
border-top-left-radius: var(--border-radius-small);
border-top-right-radius: var(--border-radius-small);
}
.commit:last-child {
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
@media (max-width: 960px) {
.history {
padding-left: 2rem;
}
}
</style>
<div class="history">
<List bind:items={history.headers} query={fetchMoreCommits}>
<svelte:fragment slot="list" let:items>
{@const headers = groupCommits(items)}
{#each headers as group (group.time)}
<div class="commit-group">
<header class="commit-date">
<p>{group.date}</p>
</header>
<div class="commit-group-headers">
{#each group.commits as commit (commit.header.sha1)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="commit"
on:click={() => {
router.updateProjectRoute({
view: { resource: "commits" },
revision: commit.header.sha1,
});
}}>
<CommitTeaser {commit} on:browseCommit={browseCommit} />
</div>
{/each}
</div>
</div>
{/each}
</svelte:fragment>
</List>
</div>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Blob, Project } from "@app/lib/project";
import { canonicalize, capitalize } from "@app/lib/utils";
import { formatObjectId } from "@app/lib/cobs";
import Comment from "@app/components/Comment.svelte";
import type { Issue } from "@app/lib/issue";
import Authorship from "@app/components/Authorship.svelte";
export let issue: Issue;
export let project: Project;
export let wallet: Wallet;
// Get an image blob based on a relative path.
const getImage = async (imagePath: string): Promise<Blob> => {
const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
return project.getBlob(commit, finalPath);
};
</script>
<style>
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
main {
display: flex;
}
.issue {
padding: 0 2rem 0 8rem;
}
.comments {
flex: 1;
}
.metadata {
flex-basis: 18rem;
margin-left: 1rem;
border-radius: var(--border-radius);
font-size: var(--font-size-small);
padding-left: 1rem;
}
.metadata-section {
margin-bottom: 1rem;
border-bottom: 1px dashed var(--color-foreground-4);
}
.metadata-section-header {
font-size: var(--font-size-small);
margin-bottom: 0.75rem;
color: var(--color-foreground-5);
}
.metadata-section-body {
margin-bottom: 1.25rem;
}
.metadata-section-empty {
color: var(--color-foreground-6);
}
.label {
border-radius: var(--border-radius);
color: var(--color-tertiary);
background-color: var(--color-tertiary-2);
padding: 0.25rem 0.75rem;
margin-right: 0.5rem;
font-size: var(--font-size-small);
line-height: 1.6;
}
.summary {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.summary-left {
display: flex;
align-items: center;
}
.summary-title {
display: flex;
}
.id {
font-size: var(--font-size-tiny);
margin-left: 0.75rem;
color: var(--color-foreground-5);
}
.summary-state {
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.open {
color: var(--color-positive);
background-color: var(--color-positive-2);
}
.closed {
color: var(--color-negative);
background-color: var(--color-negative-2);
}
.replies {
margin-left: 2rem;
}
@media (max-width: 960px) {
.issue {
padding-left: 2rem;
}
}
</style>
<div class="issue">
<header>
<div class="summary">
<div class="summary-left">
<span class="summary-title txt-medium">
{issue.title}
</span>
<span class="txt-monospace id layout-desktop">{issue.id}</span>
<span class="txt-monospace id layout-mobile">
{formatObjectId(issue.id)}
</span>
</div>
<div
class="summary-state"
class:closed={issue.state.status === "closed"}
class:open={issue.state.status === "open"}>
{capitalize(issue.state.status)}
</div>
</div>
<Authorship
{wallet}
author={issue.author}
timestamp={issue.timestamp}
caption="opened on" />
</header>
<main>
<div class="comments">
<Comment comment={issue.comment} {getImage} {wallet} />
{#each issue.discussion as comment}
<Comment {comment} {getImage} {wallet} />
{#if comment.replies}
<div class="replies">
{#each comment.replies as reply}
<Comment comment={reply} {getImage} {wallet} />
{/each}
</div>
{/if}
{/each}
</div>
<div class="metadata layout-desktop">
<div class="metadata-section">
<div class="metadata-section-header">Labels</div>
<div class="metadata-section-body">
{#if issue.labels?.length}
{#each issue.labels as label}
<span class="label">{label}</span>
{/each}
{:else}
<div class="metadata-section-empty">No labels.</div>
{/if}
</div>
</div>
</div>
</main>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import type { Issue } from "@app/lib/issue";
import type { Wallet } from "@app/lib/wallet";
import { Profile, ProfileType } from "@app/lib/profile";
import { formatObjectId } from "@app/lib/cobs";
import { onMount } from "svelte";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
export let issue: Issue;
export let wallet: Wallet;
let profile: Profile | null = null;
onMount(async () => {
if (issue.author.profile?.ens?.name) {
profile = await Profile.get(
issue.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
const commentCount = issue.countComments();
</script>
<style>
.issue-teaser {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-foreground-1);
padding: 0.75rem 0;
}
.issue-teaser:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.issue-id {
color: var(--color-foreground-5);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
margin-left: 0.5rem;
}
.column-left {
flex: min-content;
}
.column-right {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 1rem;
flex-basis: 5rem;
}
.comment-count {
color: var(--color-foreground-4);
font-weight: var(--font-weight-bold);
}
.comment-count .emoji {
margin-right: 0.25rem;
}
.state {
padding: 0 1rem;
}
.state-icon {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--border-radius-small);
}
.open {
background-color: var(--color-positive);
}
.closed {
background-color: var(--color-negative);
}
.summary {
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
@media (max-width: 720px) {
.column-left {
overflow: hidden;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="issue-teaser">
<div class="state">
<div
class="state-icon"
class:closed={issue.state.status === "closed"}
class:open={issue.state.status === "open"} />
</div>
<div class="column-left">
<div class="summary">
<!-- TODO: Truncation not working on overflow -->
{issue.title}
<span class="issue-id">{formatObjectId(issue.id)}</span>
</div>
<Authorship
{profile}
{wallet}
caption="opened"
author={issue.author}
timestamp={issue.timestamp} />
</div>
{#if commentCount > 0}
<div class="column-right">
<div class="comment-count">
<span class="txt-tiny emoji" use:twemoji>💬</span>
<span>{commentCount}</span>
</div>
</div>
{/if}
</div>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts" context="module">
export type State = "open" | "closed";
</script>
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Issue } from "@app/lib/issue";
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import { capitalize } from "@app/lib/utils";
import { groupIssues } from "@app/lib/issue";
import * as router from "@app/lib/router";
import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
export let wallet: Wallet;
export let issues: Issue[];
export let state: State;
let options: ToggleButtonOption<State>[];
const { open, closed } = groupIssues(issues);
$: filteredIssues = state === "open" ? open : closed;
$: sortedIssues = filteredIssues.sort(
({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
);
$: options = [
{
value: "open",
count: open.length,
},
{
value: "closed",
count: closed.length,
},
];
</script>
<style>
.issues {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.issues-list {
border-radius: var(--border-radius);
overflow: hidden;
}
.teaser:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
@media (max-width: 960px) {
.issues {
padding-left: 2rem;
}
}
</style>
<div class="issues">
<div style="margin-bottom: 1rem;">
<ToggleButton
{options}
on:select={e => {
router.updateProjectRoute({
search: e.detail,
});
}}
active={state} />
</div>
{#if filteredIssues.length}
<div class="issues-list">
{#each sortedIssues as issue}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="teaser"
on:click={() => {
router.updateProjectRoute({
view: {
resource: "issue",
params: { issue: issue.id },
},
});
}}>
<IssueTeaser {wallet} {issue} />
</div>
{/each}
</div>
{:else}
<Placeholder emoji="🍣">
<div slot="title">{capitalize(state)} issues</div>
<div slot="body">No issues matched the current filter</div>
</Placeholder>
{/if}
</div>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Project } from "@app/lib/project";
import { capitalize } from "@app/lib/utils";
import { Patch, PatchTab } from "@app/lib/patch";
import { formatObjectId } from "@app/lib/cobs";
import Authorship from "@app/components/Authorship.svelte";
import Changeset from "./SourceBrowser/Changeset.svelte";
import PatchSideBar from "./Patch/PatchSideBar.svelte";
import PatchTabBar from "./Patch/PatchTabBar.svelte";
import PatchTimeline from "./Patch/PatchTimeline.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import * as router from "@app/lib/router";
export let patch: Patch;
export let project: Project;
export let wallet: Wallet;
const onSwitch = ({ detail }: { detail: PatchTab }) => {
activeTab = detail;
};
const onRevisionChanged = ({ detail }: { detail: string }) => {
revisionNumber = parseInt(detail);
};
const onBrowse = (event: { detail: string }, revision: string) => {
router.updateProjectRoute({
view: { resource: "tree" },
revision,
path: event.detail,
});
};
let activeTab = PatchTab.Timeline;
let revisionNumber = patch.revisions.length - 1;
$: revision = patch.revisions[revisionNumber];
</script>
<style>
header {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
}
.patch {
padding: 0 2rem 0 8rem;
}
.summary {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.summary-left {
display: flex;
flex-direction: column;
}
.summary-title {
display: flex;
margin-right: 0.75rem;
}
.id {
font-size: var(--font-size-tiny);
color: var(--color-foreground-5);
}
.summary-state {
padding: 0.5rem 1rem;
border-radius: 1.25rem;
}
.proposed {
color: var(--color-positive-6);
background-color: var(--color-positive-1);
}
.draft {
color: var(--color-positive-6);
background-color: var(--color-positive-1);
}
.archived {
color: var(--color-negative-6);
background-color: var(--color-negative-1);
}
.flex {
display: flex;
}
@media (max-width: 960px) {
.patch {
padding-left: 2rem;
}
}
</style>
<div class="patch">
<header>
<div class="summary">
<div class="summary-left">
<span class="summary-title txt-medium">
{patch.title}
</span>
<span class="txt-monospace id layout-desktop">{patch.id}</span>
<span class="txt-monospace id layout-mobile">
{formatObjectId(patch.id)}
</span>
</div>
<div
class="summary-state"
class:proposed={patch.state === "proposed"}
class:draft={patch.state === "draft"}
class:archived={patch.state === "archived"}>
{capitalize(patch.state)}
</div>
</div>
<Authorship
noAvatar
{wallet}
author={patch.author}
timestamp={patch.timestamp}
caption="opened" />
</header>
<PatchTabBar
{activeTab}
{revisionNumber}
revisions={patch.revisions}
on:switchTab={onSwitch}
on:revisionChanged={onRevisionChanged} />
<main>
{#if activeTab === PatchTab.Timeline}
<div class="flex">
<PatchTimeline {patch} {revisionNumber} {wallet} {project} />
<PatchSideBar {patch} />
</div>
{:else if activeTab === PatchTab.Diff && revision.changeset}
<Changeset
diff={revision.changeset.diff}
stats={revision.changeset.stats}
on:browse={e => onBrowse(e, revision.oid)} />
{:else if activeTab === PatchTab.Diff}
<Placeholder emoji="🍳">
<span slot="title">No changeset found</span>
<span slot="body">
We couldn't find a changeset related to this patch or revision
</span>
</Placeholder>
{/if}
</main>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
export let patch: Patch;
</script>
<style>
.metadata {
flex-basis: 18rem;
margin-left: 1rem;
border-radius: var(--border-radius-medium);
font-size: var(--font-size-small);
padding-left: 1rem;
}
.metadata-section {
margin-bottom: 1rem;
border-bottom: 1px dashed var(--color-foreground-4);
}
.metadata-section-header {
font-size: var(--font-size-small);
margin-bottom: 0.75rem;
color: var(--color-foreground-5);
}
.metadata-section-body {
margin-bottom: 1.25rem;
}
.metadata-section-empty {
color: var(--color-foreground-6);
}
.label {
border-radius: var(--border-radius);
color: var(--color-tertiary);
background-color: var(--color-tertiary-2);
padding: 0.25rem 0.75rem;
margin-right: 0.5rem;
font-size: var(--font-size-small);
line-height: 1.6;
}
</style>
<div class="metadata layout-desktop">
<div class="metadata-section">
<div class="metadata-section-header">Labels</div>
<div class="metadata-section-body">
{#if patch.labels?.length}
{#each patch.labels as label}
<span class="label">{label}</span>
{/each}
{:else}
<div class="metadata-section-empty">No labels.</div>
{/if}
</div>
</div>
</div>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" strictEvents>
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating, { closeFocused } from "@app/components/Floating.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
import type { Revision } from "@app/lib/patch";
import { PatchTab } from "@app/lib/patch";
import { formatCommit, formatTimestamp } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
export let revisions: Revision[];
export let revisionNumber: number;
export let activeTab: PatchTab;
const dispatch = createEventDispatcher<{
switchTab: PatchTab;
revisionChanged: string;
}>();
const formatRevisionName = (revision: Revision, index: number) => {
return `R${index} ${formatCommit(revision.oid)} ${formatTimestamp(
revision.timestamp,
)}`;
};
const revisionList = Object.values(revisions).map((b, i) => ({
key: formatRevisionName(b, i),
value: i.toString(),
title: `Browse revision ${formatRevisionName(b, i)}`,
badge: null,
}));
const onRevisionChange = ({ detail }: { detail: string }) => {
closeFocused();
dispatch("revisionChanged", detail);
};
let options: ToggleButtonOption<PatchTab>[];
$: options = [
{
title: "Patch",
value: PatchTab.Timeline,
},
{
title: "Changeset",
value: PatchTab.Diff,
},
];
</script>
<style>
.bar {
align-items: center;
display: flex;
gap: 1rem;
margin: 1.5rem 0;
}
.revision-toggle {
border-radius: var(--border-radius-small);
border: none;
color: var(--color-foreground-6);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
height: var(--button-tiny-height);
padding: 0.25rem 0.5rem;
background-color: var(--color-background);
}
.revision-toggle:hover {
background-color: var(--color-foreground-1);
color: var(--color-foreground);
}
.revision-toggle:disabled {
color: var(--color-foreground-5);
}
</style>
<div class="bar txt-small">
<ToggleButton
{options}
on:select={e => {
dispatch("switchTab", e.detail);
}}
active={activeTab} />
<Floating disabled={revisions.length <= 1}>
<button
slot="toggle"
class="txt-small revision-toggle"
disabled={revisions.length <= 1}>
{formatRevisionName(revisions[revisionNumber], revisionNumber)}
</button>
<svelte:fragment slot="modal">
<Dropdown
items={revisionList}
selected={revisionNumber.toString()}
on:select={onRevisionChange} />
</svelte:fragment>
</Floating>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
import type { Wallet } from "@app/lib/wallet";
import { Profile, ProfileType } from "@app/lib/profile";
import { formatObjectId } from "@app/lib/cobs";
import { onMount } from "svelte";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
export let patch: Patch;
export let wallet: Wallet;
let profile: Profile | null = null;
onMount(async () => {
if (patch.author.profile?.ens?.name) {
profile = await Profile.get(
patch.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
const commentCount = patch.countComments(patch.revisions.length - 1);
</script>
<style>
.patch-teaser {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-foreground-1);
padding: 0.75rem 0;
}
.patch-teaser:hover {
background-color: var(--color-foreground-2);
cursor: pointer;
}
.patch-id {
color: var(--color-foreground-5);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
margin-left: 0.5rem;
}
.column-left {
flex: min-content;
}
.column-right {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 1rem;
flex-basis: 5rem;
}
.comment-count {
color: var(--color-foreground-4);
font-weight: var(--font-weight-bold);
}
.comment-count .emoji {
margin-right: 0.25rem;
}
.state {
padding: 0 1rem;
}
.state-icon {
width: 0.5rem;
height: 0.5rem;
border-radius: 0.5rem;
}
.open {
background-color: var(--color-positive);
}
.closed {
background-color: var(--color-negative);
}
.summary {
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
@media (max-width: 720px) {
.column-left {
overflow: hidden;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="patch-teaser">
<div class="state">
<div
class="state-icon"
class:closed={patch.state === "archived"}
class:open={patch.state === "proposed"} />
</div>
<div class="column-left">
<div class="summary">
<!-- TODO: Truncation not working on overflow -->
{patch.title}
<span class="patch-id">{formatObjectId(patch.id)}</span>
</div>
<Authorship
{profile}
{wallet}
caption="opened"
author={patch.author}
timestamp={patch.timestamp} />
</div>
{#if commentCount > 0}
<div class="column-right">
<div class="comment-count">
<span class="txt-tiny emoji" use:twemoji>💬</span>
<span>{commentCount}</span>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import { type Patch, TimelineType } from "@app/lib/patch";
import { formatSeedId } from "@app/lib/utils";
import { canonicalize } from "@app/lib/utils";
import Comment from "@app/components/Comment.svelte";
import type { Blob, Project } from "@app/lib/project";
import Authorship from "@app/components/Authorship.svelte";
import Review from "@app/views/projects/Patch/Review.svelte";
export let patch: Patch;
export let revisionNumber: number;
export let wallet: Wallet;
export let project: Project;
$: timeline = patch.createTimeline(revisionNumber);
// Get an image blob based on a relative path.
const getImage = async (imagePath: string): Promise<Blob> => {
const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
return project.getBlob(commit, finalPath);
};
</script>
<style>
.timeline {
display: flex;
flex-direction: column;
flex: 1;
}
.replies {
margin-left: 2rem;
}
.element {
margin: 0 0 1rem 3rem;
}
</style>
<div class="timeline">
{#each timeline as element}
{#if element.type === TimelineType.Merge && element.inner.peer.person}
<div class="element">
<Authorship
author={{
peer: element.inner.peer.id,
urn: element.inner.peer.person.urn,
profile: element.inner.peer.person,
}}
caption={`merged to ${formatSeedId(element.inner.peer.id)}`}
timestamp={element.timestamp}
{wallet} />
</div>
{:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
<div class="margin-left">
<Review review={element.inner} {wallet} {getImage} />
</div>
{:else if element.type === TimelineType.Comment}
<div class="margin-left">
<!-- Since the element variable only experiences changes on the inner property,
this component has to be forced to be rerendered when element.inner changes -->
{#key element.inner}
<Comment comment={element.inner} {wallet} {getImage} />
{/key}
</div>
{:else if element.type === TimelineType.Thread}
<div class="margin-left">
<Comment comment={element.inner} {wallet} {getImage} />
<div class="replies">
{#each element.inner.replies as comment}
<Comment caption="replied" {comment} {wallet} {getImage} />
{/each}
</div>
</div>
{/if}
{/each}
</div>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Wallet } from "@app/lib/wallet";
import type { Review } from "@app/lib/patch";
import { formatVerdict } from "@app/lib/patch";
import type { Blob } from "@app/lib/project";
import { Profile, ProfileType } from "@app/lib/profile";
import Authorship from "@app/components/Authorship.svelte";
import Comment from "@app/components/Comment.svelte";
export let review: Review;
export let wallet: Wallet;
export let getImage: (path: string) => Promise<Blob>;
let profile: Profile | null = null;
onMount(async () => {
if (review.author.profile?.ens?.name) {
profile = await Profile.get(
review.author.profile.ens.name,
ProfileType.Minimal,
wallet,
);
}
});
</script>
<style>
div {
margin: 0 0 1rem 3rem;
}
</style>
{#if review.comment.body}
<Comment
{wallet}
{getImage}
comment={review.comment}
caption={formatVerdict(review.verdict)} />
{:else}
<div>
<Authorship
{wallet}
{profile}
author={review.author}
timestamp={review.timestamp}
caption={formatVerdict(review.verdict)} />
</div>
{/if}
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts" context="module">
export type State = "proposed" | "draft" | "archived";
</script>
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { Patch } from "@app/lib/patch";
import type { ToggleButtonOption } from "@app/components/ToggleButton.svelte";
import PatchTeaser from "./Patch/PatchTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ToggleButton from "@app/components/ToggleButton.svelte";
import { capitalize } from "@app/lib/utils";
import { groupPatches } from "@app/lib/patch";
import * as router from "@app/lib/router";
export let state: State;
export let wallet: Wallet;
export let patches: Patch[];
let options: ToggleButtonOption<State>[];
const sortedPatches = groupPatches(patches);
$: filteredPatches = sortedPatches[state];
$: options = [
{
value: "proposed",
count: sortedPatches.proposed.length,
},
{
value: "draft",
count: sortedPatches.draft.length,
},
{
value: "archived",
count: sortedPatches.archived.length,
},
];
</script>
<style>
.patches {
padding: 0 2rem 0 8rem;
font-size: var(--font-size-small);
}
.patches-list {
border-radius: var(--border-radius-small);
overflow: hidden;
}
.teaser:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
@media (max-width: 960px) {
.patches {
padding-left: 2rem;
}
}
</style>
<div class="patches">
<div style="margin-bottom: 1rem;">
<ToggleButton
{options}
on:select={e => {
router.updateProjectRoute({
search: e.detail,
});
}}
active={state} />
</div>
{#if filteredPatches.length}
<div class="patches-list">
{#each filteredPatches as patch}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="teaser"
on:click={() => {
router.updateProjectRoute({
view: { resource: "patch", params: { patch: patch.id } },
});
}}>
<PatchTeaser {wallet} {patch} />
</div>
{/each}
</div>
{:else}
<Placeholder emoji="🍖">
<div slot="title">{capitalize(state)} patches</div>
<div slot="body">No patches matched the current filter</div>
</Placeholder>
{/if}
</div>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts" strictEvents>
import { createEventDispatcher, onMount } from "svelte";
import Icon from "@app/components/Icon.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import { formatSeedId } from "@app/lib/utils";
import type { Peer } from "@app/lib/project";
import Floating from "@app/components/Floating.svelte";
import Badge from "@app/components/Badge.svelte";
export let peer: string | null = null;
export let peers: Peer[];
let meta: Peer | undefined;
// List of items to be created for the Dropdown component.
let items: {
key: string;
value: string;
title: string;
badge: string | null;
}[] = [];
function createTitle(p: Peer): string {
const name = p.person?.name ? p.person.name : p.id;
return p.delegate
? `${name} is a delegate of this project`
: `${name} is a peer tracked by this seed`;
}
onMount(() => {
meta = peers.find(p => p.id === peer);
items = peers.map(p => {
if (!p.person?.name) {
console.debug("Not able to resolve peer identity for: ", p.id);
}
const key = p.person?.name
? `<span class="txt-bold">${p.person.name}</span> ${p.id}`
: p.id;
return {
key,
value: p.id,
title: createTitle(p),
badge: p.delegate ? "delegate" : null,
};
});
});
const dispatch = createEventDispatcher<{ peerChanged: string }>();
const switchPeer = (peer: string) => {
dispatch("peerChanged", peer);
};
</script>
<style>
.selector {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-monospace);
}
.selector .peer {
padding: 0.5rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
border-radius: var(--border-radius-small);
}
.selector .peer.not-allowed {
cursor: not-allowed;
}
.peer-id {
margin: 0 0.5rem;
}
.peer:hover {
background-color: var(--color-foreground-2);
}
.stat {
display: flex;
align-items: center;
font-family: var(--font-family-monospace);
padding: 0.5rem;
height: 2rem;
line-height: initial;
background: var(--color-foreground-1);
}
</style>
<Floating>
<div slot="toggle" class="selector" title="Change peer">
<div class="stat peer" class:not-allowed={!peers}>
<Icon name="fork" />
{#if meta}
<span class="peer-id">
{meta.person?.name ?? formatSeedId(meta.id)}
</span>
{#if meta.delegate}
<Badge variant="primary">delegate</Badge>
{/if}
<!-- If the delegate metadata is not found -->
{:else if peer}
<span class="peer-id">
{formatSeedId(peer)}
</span>
{/if}
</div>
</div>
<svelte:fragment slot="modal">
<Dropdown {items} selected={peer} on:select={e => switchPeer(e.detail)} />
</svelte:fragment>
</Floating>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import type { PeerId, Project } from "@app/lib/project";
import Avatar from "@app/components/Avatar.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
import Link from "@app/components/Link.svelte";
import ProjectLink from "@app/components/ProjectLink.svelte";
import { formatSeedId } from "@app/lib/utils";
export let project: Project;
export let peer: PeerId | null = null;
</script>
<style>
.title {
display: flex;
align-items: center;
justify-content: left;
font-size: var(--font-size-huge);
margin-bottom: 0.5rem;
}
.title .divider {
color: var(--color-foreground-4);
margin: 0 0.5rem;
font-weight: var(--font-weight-normal);
}
.title .peer-id {
color: var(--color-foreground-5);
font-weight: var(--font-weight-normal);
display: flex;
align-items: center;
}
.org-avatar {
display: inline-block;
width: 2rem;
height: 2rem;
}
.project-name:hover {
color: inherit;
}
.urn {
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
color: var(--color-foreground-5);
overflow-wrap: anywhere;
display: flex;
justify-content: left;
align-items: center;
gap: 0.125rem;
}
.description {
margin: 1rem 0 1.5rem 0;
}
.content {
padding: 0 2rem 0 8rem;
}
.truncate {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
@media (max-width: 960px) {
.content {
padding-left: 2rem;
}
}
</style>
<header class="content">
<div class="title txt-bold txt-title">
{#if project.profile}
<Link
route={{
resource: "profile",
params: { addressOrName: project.profile.addressOrName },
}}
title={project.profile.addressOrName}>
<span class="org-avatar">
<Avatar
source={project.profile.avatar || project.profile.address}
title={project.profile.address} />
</span>
</Link>
<span class="divider">/</span>
{/if}
<span class="truncate">
<ProjectLink
projectParams={{
view: { resource: "tree" },
path: "/",
peer: undefined,
route: undefined,
revision: undefined,
}}>
<span class="project-name">
{project.name}
</span>
</ProjectLink>
</span>
{#if peer}
<span class="peer-id">
<span class="divider">/</span>
<span title={peer}>{formatSeedId(peer)}</span>
<Clipboard text={peer} />
</span>
{/if}
</div>
<div class="urn">
<span class="truncate">{project.urn}</span>
<Clipboard small text={project.urn} />
</div>
<div class="description">{project.description}</div>
</header>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type * as proj from "@app/lib/project";
import type { ProjectRoute } from "@app/lib/router/definitions";
import Markdown from "@app/components/Markdown.svelte";
export let content: string;
export let getImage: (path: string) => Promise<proj.MaybeBlob>;
export let activeRoute: ProjectRoute;
$: hash = activeRoute.params.hash || null;
</script>
<style>
article {
padding: 2rem;
width: 100%;
background: var(--color-foreground-1);
border-bottom-left-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
</style>
<article>
<Markdown {content} {getImage} {hash} />
</article>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { DiffStats } from "@app/lib/commit";
import type { Diff } from "@app/lib/diff";
import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
export let diff: Diff;
export let stats: DiffStats;
const diffDescription = ({ modified, created, deleted }: Diff): string => {
const s = [];
if (modified.length) {
s.push(`${modified.length} file(s) changed`);
}
if (created.length) {
s.push(`${created.length} file(s) created`);
}
if (deleted.length) {
s.push(`${deleted.length} file(s) deleted`);
}
return s.join(", ");
};
</script>
<style>
.changeset-summary {
padding-bottom: 1.5rem;
margin-left: 1rem;
}
.changeset-summary .additions {
color: var(--color-positive);
}
.changeset-summary .deletions {
color: var(--color-negative);
}
</style>
<div class="changeset-summary">
<span>{diffDescription(diff)}</span>
with
<span class="additions">{stats.additions} addition(s)</span>
and
<span class="deletions">{stats.deletions} deletion(s)</span>
</div>
<div class="diff-listing">
{#each diff.created as file}
<FileDiff on:browse {file} mode="created" />
{/each}
{#each diff.deleted as file}
<FileDiff on:browse {file} mode="deleted" />
{/each}
{#each diff.modified as file}
<FileDiff on:browse {file} />
{/each}
</div>
@@ -0,0 +1,167 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import Icon from "@app/components/Icon.svelte";
import { lineNumberL, lineNumberR, lineSign } from "@app/lib/diff";
import type { FileDiff } from "@app/lib/diff";
import Badge from "@app/components/Badge.svelte";
const dispatch = createEventDispatcher<{ browse: string }>();
export let file: FileDiff;
export let mode: string | null = null;
function collapse() {
collapsed = !collapsed;
}
let collapsed = false;
</script>
<style>
.changeset-file {
border: 1px solid var(--color-foreground-4);
border-radius: var(--border-radius-small);
min-width: var(--content-min-width);
margin-bottom: 2rem;
line-height: 1.5rem;
}
.changeset-file header {
cursor: pointer;
height: 3rem;
display: flex;
align-items: center;
background: none;
border-radius: 0;
padding: 1rem;
}
main {
border-top: 1px dashed var(--color-foreground-4);
}
.changeset-file main {
overflow-x: auto;
}
header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
.binary {
padding: 1rem;
color: var(--color-foreground-5);
text-align: center;
background-color: var(--color-foreground-2);
}
table.diff {
font-family: var(--font-family-monospace);
table-layout: fixed;
border-collapse: collapse;
margin: 0.5rem 0;
}
tr.diff-line[data-type="+"] > * {
color: var(--color-positive);
}
tr.diff-line[data-type="-"] > * {
color: var(--color-negative);
}
td.diff-line-number {
text-align: right;
user-select: none;
line-height: 1.5rem;
min-width: 3rem;
}
td.diff-line-number[data-type="+"],
td.diff-line-type[data-type="+"] {
background-color: var(--color-positive-2);
color: var(--color-positive-6);
}
td.diff-line-number[data-type="-"],
td.diff-line-type[data-type="-"] {
background-color: var(--color-negative-2);
color: var(--color-negative-6);
}
td.diff-line-number.left {
padding: 0 0.5rem 0 0.75rem;
}
td.diff-line-number.right {
padding: 0 0.75rem 0 0.5rem;
}
td.diff-line-content {
white-space: pre;
width: 100%;
padding-right: 0.5rem;
}
td.diff-line-type {
text-align: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
td.diff-expand-header {
padding-left: 0.5rem;
color: var(--color-foreground-5);
}
td.diff-line-number {
color: var(--color-foreground-5);
}
.browse {
display: flex;
}
</style>
<article id={file.path} class="changeset-file">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<header class="file-header" on:click={collapse}>
<div class="actions">
<p class="txt-regular">{file.path}</p>
{#if mode === "created"}
<Badge variant="positive">created</Badge>
{:else if mode === "deleted"}
<Badge variant="negative">deleted</Badge>
{/if}
</div>
<div
class="browse clickable"
on:click|stopPropagation={() => dispatch("browse", file.path)}>
<span title="View file" style="transform: scale(1.25);">
<Icon name="browse" />
</span>
</div>
</header>
{#if !collapsed}
<main>
{#if file.diff.type === "plain" && file.diff.hunks.length > 0}
<table class="diff">
{#each file.diff.hunks as hunk}
<tr class="diff-line">
<td colspan={2} />
<td colspan={6} class="diff-expand-header">
{hunk.header}
</td>
</tr>
{#each hunk.lines as line}
<tr class="diff-line" data-expanded data-type={lineSign(line)}>
<td class="diff-line-number left" data-type={lineSign(line)}>
{lineNumberL(line)}
</td>
<td class="diff-line-number right" data-type={lineSign(line)}>
{lineNumberR(line)}
</td>
<td class="diff-line-type" data-type={line.type}>
{lineSign(line)}
</td>
<td class="diff-line-content">{line.line}</td>
</tr>
{/each}
{/each}
</table>
{:else}
<div class="binary">Binary file</div>
{/if}
</main>
{/if}
</article>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts" strictEvents>
import type { MaybeTree, Tree } from "@app/lib/project";
import { createEventDispatcher } from "svelte";
import { ObjectType } from "@app/lib/project";
import File from "./Tree/File.svelte";
import Folder from "./Tree/Folder.svelte";
export let fetchTree: (path: string) => Promise<MaybeTree>;
export let path: string;
export let tree: Tree;
export let loadingPath: string | null = null;
const dispatch = createEventDispatcher<{ select: string }>();
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}
{loadingPath}
name={entry.info.name}
prefix={`${entry.path}/`}
currentPath={path}
on:select={onSelect} />
{:else}
<File
active={entry.path === path}
loading={entry.path === loadingPath}
name={entry.info.name}
on:click={() => onSelect({ detail: entry.path })} />
{/if}
{/each}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import Loading from "@app/components/Loading.svelte";
export let active: boolean;
export let loading: boolean;
export let name: string;
</script>
<style>
.file {
color: var(--color-foreground-6);
border-radius: var(--border-radius-small);
cursor: pointer;
display: flex;
justify-content: space-between;
line-height: 1.5em;
margin: 0.125rem 0;
padding: 0.25rem;
width: 100%;
}
.file:hover {
background-color: var(--color-foreground-1);
}
.file.active {
color: var(--color-foreground) !important;
background-color: var(--color-foreground-1);
}
.spinner {
align-items: center;
display: flex;
justify-content: center;
height: 24px;
width: 24px;
}
.name {
margin-left: 0.25rem;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis !important;
overflow: hidden;
max-width: 24ch;
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="file" class:active on:click>
<span class="name">{name}</span>
<div class="spinner">
{#if loading}
<Loading small condensed />
{/if}
</div>
</div>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts" strictEvents>
import type { MaybeTree } from "@app/lib/project";
import Loading from "@app/components/Loading.svelte";
import { ObjectType } from "@app/lib/project";
import { createEventDispatcher } from "svelte";
import File from "./File.svelte";
export let fetchTree: (path: string) => Promise<MaybeTree>;
export let name: string;
export let prefix: string;
export let currentPath: string;
export let loadingPath: string | null = null;
let expanded = currentPath.indexOf(prefix) === 0;
let tree: Promise<MaybeTree> = fetchTree(prefix).then(tree => {
if (expanded) return tree;
});
const dispatch = createEventDispatcher<{ select: string }>();
const onSelectFile = ({ detail: path }: { detail: string }) =>
dispatch("select", path);
const onClick = () => {
expanded = !expanded;
tree = fetchTree(prefix).then(tree => {
if (expanded) return tree;
});
};
</script>
<style>
.folder {
display: flex;
cursor: pointer;
padding: 0.25rem;
margin: 0.125rem 0;
color: var(--color-foreground-6);
user-select: none;
line-height: 1.5rem;
white-space: nowrap;
}
.folder:hover {
background-color: var(--color-foreground-1);
border-radius: var(--border-radius-small);
}
.folder-name {
margin-left: 0.25rem;
color: var(--color-secondary-6);
}
.container {
padding-left: 0.5rem;
margin: 0;
}
.loading {
display: inline-block;
padding: 0.5rem 0;
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="folder" on:click={onClick}>
<span class="folder-name">{name}/</span>
</div>
<div class="container">
{#if expanded}
{#await tree}
<span class="loading"><Loading small margins /></span>
{: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}/`}
{loadingPath}
{currentPath} />
{:else}
<File
active={entry.path === currentPath}
loading={entry.path === loadingPath}
name={entry.info.name}
on:click={() => {
onSelectFile({ detail: entry.path });
}} />
{/if}
{/each}
{/if}
{/await}
{/if}
</div>
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { ProjectRoute } from "@app/lib/router/definitions";
import type { State as IssueState } from "./Issues.svelte";
import type { State as PatchState } from "./Patches.svelte";
import * as issue from "@app/lib/issue";
import * as patch from "@app/lib/patch";
import * as proj from "@app/lib/project";
import * as router from "@app/lib/router";
import Loading from "@app/components/Loading.svelte";
import NotFound from "@app/components/NotFound.svelte";
import { formatSeedId, unreachable } from "@app/lib/utils";
import Header from "./Header.svelte";
import Browser from "./Browser.svelte";
import History from "./History.svelte";
import Commit from "./Commit.svelte";
import Issues from "./Issues.svelte";
import Issue from "./Issue.svelte";
import Patches from "./Patches.svelte";
import Patch from "./Patch.svelte";
import ProjectMeta from "./ProjectMeta.svelte";
import Message from "@app/components/Message.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
export let wallet: Wallet;
export let activeRoute: ProjectRoute;
$: urn = activeRoute.params.urn;
$: peer = activeRoute.params.peer ?? null;
$: seed = activeRoute.params.seed ?? null;
$: profile = activeRoute.params.profile ?? null;
$: searchParams = new URLSearchParams(activeRoute.params.search || "");
$: issueFilter = (searchParams.get("state") as IssueState) || "open";
$: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
const getProject = async (
urn: string,
peer: string | null,
profile: string | null,
seed: string | null,
) => {
const project = await proj.Project.get(urn, peer, profile, seed, wallet);
if (activeRoute.params.route) {
const { revision, path } = proj.parseRoute(
activeRoute.params.route,
project.branches,
);
router.updateProjectRoute(
{
revision,
path,
line: activeRoute.params.line,
hash: activeRoute.params.hash,
route: undefined,
},
{ replace: true },
);
}
if (!activeRoute.params.revision) {
// We need a revision to fetch `getRoot`.
// Don't use router.updateProjectRoute, to avoid changing the URL.
activeRoute.params.revision = project.defaultBranch;
}
return project;
};
// Content can be altered in child components.
$: revision = activeRoute.params.revision || null;
</script>
<style>
main {
width: 100%;
max-width: var(--content-max-width);
min-width: var(--content-min-width);
padding: 4rem 0;
}
main > header {
padding: 0 2rem 0 8rem;
}
main > .message {
padding: 0 2rem 0 8rem;
}
@media (max-width: 960px) {
main > header {
padding-left: 2rem;
}
main > .message {
padding-left: 2rem;
}
main {
padding-top: 2rem;
min-width: 0;
}
}
</style>
<main>
{#await getProject(urn, peer, profile, seed)}
<header>
<Loading center />
</header>
{:then project}
<ProjectMeta {project} {peer} />
{#await project.getRoot(revision)}
<Loading center />
{:then { tree, commit }}
<Header {tree} {commit} {project} {activeRoute} />
{#if activeRoute.params.view.resource === "tree"}
<Browser {project} {commit} {tree} {activeRoute} />
{:else if activeRoute.params.view.resource === "history"}
{#await proj.Project.getCommits( project.urn, project.seed.api, { parent: commit, verified: true }, )}
<Loading center />
{:then history}
<History {project} {history} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "commits"}
{#await project.getCommit(commit)}
<Loading center />
{:then commit}
<Commit {commit} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "issues"}
{#await issue.Issue.getIssues(project.urn, project.seed.api)}
<Loading center />
{:then issues}
<Issues state={issueFilter} {wallet} {issues} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "issue"}
{#await issue.Issue.getIssue(project.urn, activeRoute.params.view.params.issue, project.seed.api)}
<Loading center />
{:then issue}
<Issue {project} {wallet} {issue} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "patches"}
{#await patch.Patch.getPatches(project.urn, project.seed.api)}
<Loading center />
{:then patches}
<Patches {wallet} state={patchFilter} {patches} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "patch"}
{#await patch.Patch.getPatch(project.urn, activeRoute.params.view.params.patch, project.seed.api)}
<Loading center />
{:then patch}
<Patch {project} {wallet} {patch} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
</div>
{/await}
{:else}
{unreachable(activeRoute.params.view)}
{/if}
{:catch e}
<div class="message">
{#if peer}
<Placeholder emoji="🍂">
<span slot="title">
<span class="txt-monospace">{formatSeedId(peer)}</span>
</span>
<span slot="body">
<span style="display: block">
Couldn't load remote source tree.
</span>
<span>{e.message}</span>
</span>
</Placeholder>
{:else}
<Placeholder emoji="🍂">
<span slot="body">
<span style="display: block">Couldn't load source tree.</span>
<span>{e.message}</span>
</span>
</Placeholder>
{/if}
</div>
{/await}
{:catch}
<NotFound title={urn} subtitle="This project was not found." />
{/await}
</main>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import type * as proj from "@app/lib/project";
import Diagram from "@app/views/projects/Diagram.svelte";
import { groupCommitsByWeek } from "@app/lib/commit";
import type { Host } from "@app/lib/api";
import { Project } from "@app/lib/project";
import { formatCommit } from "@app/lib/utils";
export let project: proj.ProjectInfo;
export let seed: { api: Host };
export let faded = false;
export let compact = false;
const loadCommits = async () => {
const commits = await Project.getActivity(project.urn, seed.api);
return groupCommitsByWeek(commits.activity);
};
</script>
<style>
article {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 1rem;
border: 1px solid var(--color-secondary-5);
border-radius: var(--border-radius-small);
min-width: 36rem;
cursor: pointer;
}
article .right {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
}
article .left {
width: 50%;
}
div .description {
overflow-x: hidden;
overflow-y: hidden;
text-overflow: ellipsis;
}
article.compact {
min-width: 16rem;
height: 9rem;
}
article.compact .left {
width: 100%;
}
article.compact .right {
display: none;
}
article.compact .description {
white-space: nowrap;
}
article.project-faded {
border: 1px dashed var(--color-foreground-4);
cursor: not-allowed;
}
.activity {
width: 100%;
max-width: 14rem;
}
article:hover {
border-color: var(--color-secondary);
background-color: var(--color-secondary-1);
}
article.project-faded:hover {
border-color: var(--color-foreground-5);
}
article .id {
font-size: var(--font-size-regular);
font-weight: var(--font-weight-medium);
margin-bottom: 0.5rem;
}
article .description {
margin-bottom: 0.25rem;
font-size: var(--font-size-tiny);
}
article .stateHash {
color: var(--color-secondary);
font-size: var(--font-size-tiny);
font-family: var(--font-family-monospace);
min-height: 2rem;
display: flex;
align-items: center;
}
article .id {
display: flex;
justify-content: space-between;
}
article .id .urn {
visibility: hidden;
color: var(--color-foreground-5);
font-weight: var(--font-weight-normal);
font-family: var(--font-family-monospace);
font-size: var(--font-size-tiny);
}
article:hover .id .urn {
visibility: visible;
}
@media (max-width: 720px) {
article {
min-width: 0;
}
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<article on:click class:project-faded={faded} class:compact>
<div class="left">
<div class="id">
<span class="name">{project.name}</span>
</div>
{#if project.description}
<div class="description">{project.description}</div>
{:else}
<div class="description txt-missing">No description</div>
{/if}
<div class="stateHash">
{#if project.head}
{#if compact}
{formatCommit(project.head)}
{:else}
{project.head}
{/if}
{:else}
<span class="txt-missing">✗ No head</span>
{/if}
</div>
{#if compact}
{#await loadCommits() then points}
<div class="activity">
<Diagram
{points}
strokeWidth={3}
viewBoxHeight={70}
viewBoxWidth={600} />
</div>
{/await}
{/if}
</div>
{#if !compact}
<div class="right">
<div class="id">
<span class="urn layout-desktop">{project.urn}</span>
</div>
{#await loadCommits() then points}
<div class="layout-desktop activity">
<Diagram
{points}
strokeWidth={3}
viewBoxHeight={100}
viewBoxWidth={600} />
</div>
{/await}
</div>
{/if}
</article>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
export let startBlock: number;
export let duration: number;
export let latestBlock: number;
let progress: number = 0;
$: if (latestBlock < startBlock + duration) {
progress = (latestBlock - startBlock) * Math.floor(100 / duration);
} else {
progress = 100;
}
</script>
<style>
.container {
text-align: center;
height: 0.5rem;
width: 100%;
border-radius: var(--border-radius-small);
background-color: var(--color-secondary-2);
}
.progress-bar {
height: 0.5rem;
width: 0px;
border-radius: var(--border-radius-small);
background-color: var(--color-secondary);
}
</style>
<div class="container">
<div class="progress-bar" style:width={`${progress}%`} />
</div>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import TextInput from "@app/components/TextInput.svelte";
export let wallet: Wallet;
let input = "";
let valid: boolean = false;
let validationMessage: string | undefined = undefined;
function register() {
if (!valid) {
return;
}
router.push({
resource: "registrations",
params: {
view: {
resource: "checkNameAvailability",
params: { nameOrDomain: ensName, owner: null },
},
},
});
}
function validate(input: string) {
if (input === "") {
return { valid: false };
}
if (input && input.includes(".")) {
return {
valid: false,
validationMessage: "Please do not use dots as separators.",
};
}
if (input && input.length < 2) {
return {
valid: false,
validationMessage: "Please enter a minimum of 2 characters.",
};
}
if (input && input.length > 128) {
return {
valid: false,
validationMessage: "Please enter a maximum of 128 characters.",
};
}
return { valid: true };
}
$: ensName = input.trim();
$: ({ valid, validationMessage } = validate(ensName));
</script>
<style>
main {
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 32rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.subtitle {
color: var(--color-secondary);
}
.form {
display: flex;
gap: 1rem;
}
</style>
<svelte:head>
<title>Radicle &ndash; Register</title>
</svelte:head>
<main>
<div class="title">
Register a <span class="txt-bold">{wallet.registrar.domain}</span>
name
</div>
<div class="subtitle">
Register a unique name with our ENS registrar, under <br />
the
<span class="txt-bold">{wallet.registrar.domain}</span>
domain (e.g. cloudhead.{wallet.registrar.domain}).
<br />
Radicle names never expire and free to register.
</div>
<div class="form">
<TextInput
bind:value={input}
autofocus
on:submit={register}
{valid}
{validationMessage}>
<svelte:fragment slot="right">
.{wallet.registrar.domain}
</svelte:fragment>
</TextInput>
<Button
disabled={!valid}
variant="primary"
size="regular"
on:click={register}>
Check
</Button>
</div>
</main>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import { onMount } from "svelte";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import Connect from "@app/components/Connect.svelte";
import Loading from "@app/components/Loading.svelte";
import Message from "@app/components/Message.svelte";
import Modal from "@app/components/Modal.svelte";
import { formatAddress, twemoji } from "@app/lib/utils";
import { registrar } from "@app/lib/registrar";
import { session } from "@app/lib/session";
enum State {
CheckingAvailability,
CheckingFailed,
NameAvailable,
NameUnavailable,
}
export let wallet: Wallet;
export let name: string;
export let owner: string | null;
// We only support lower-case names.
name = name.toLowerCase();
let state = State.CheckingAvailability;
let error: string | null = null;
$: registrationOwner = owner || ($session && $session.address);
function begin() {
router.push({
resource: "registrations",
params: {
view: {
resource: "register",
params: { nameOrDomain: name, owner: registrationOwner },
},
},
});
}
onMount(async () => {
try {
const isAvailable = await registrar(wallet).available(name);
if (isAvailable) {
state = State.NameAvailable;
} else {
state = State.NameUnavailable;
}
} catch (err: any) {
state = State.CheckingFailed;
error = err.message;
}
});
function goToValidateName() {
router.push({
resource: "registrations",
params: { view: { resource: "validateName" } },
});
}
</script>
<style>
.actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
</style>
<svelte:head>
<title>Radicle: Register {name}</title>
</svelte:head>
<Modal narrow>
<span slot="title">
<div use:twemoji>🌐</div>
<span>Register a name</span>
</span>
<span slot="subtitle">
{name}.{wallet.registrar.domain}
</span>
<span slot="body">
{#if state === State.NameAvailable}
{#if registrationOwner}
The name <span class="txt-bold">{name}</span>
is available for registration under account
<span class="txt-bold">{formatAddress(registrationOwner)}</span>
.
{:else}
The name <span class="txt-bold">{name}</span>
is available.
{/if}
{:else if state === State.NameUnavailable}
This name is <span class="txt-bold">not available</span>
for registration.
{:else if state === State.CheckingAvailability}
<Loading small center />
{:else if state === State.CheckingFailed && error}
<Message error>
<span class="txt-bold">Error:</span>
{error}
</Message>
{/if}
</span>
<span class="actions" slot="actions">
{#if state === State.NameAvailable}
{#if $session}
<Button on:click={begin} variant="primary">
Begin registration &rarr;
</Button>
{:else}
<Connect
caption="Connect to register"
buttonVariant="primary"
{wallet} />
{/if}
<Button on:click={goToValidateName} variant="text">Cancel</Button>
{:else if state === State.NameUnavailable || state === State.CheckingFailed}
<Button variant="foreground" on:click={goToValidateName}>Back</Button>
{/if}
</span>
</Modal>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { RegistrationRoute } from "@app/lib/router/definitions";
import type { Session } from "@app/lib/session";
import { unreachable } from "@app/lib/utils";
import type { Wallet } from "@app/lib/wallet";
import * as router from "@app/lib/router";
import New from "@app/views/registrations/New.svelte";
import Submit from "@app/views/registrations/Submit.svelte";
import Index from "@app/views/registrations/Index.svelte";
import View from "@app/views/registrations/View.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
export let wallet: Wallet;
export let activeRoute: RegistrationRoute;
export let session: Session | null;
</script>
{#if activeRoute.params.view.resource === "validateName"}
<Index {wallet} />
{:else if activeRoute.params.view.resource === "checkNameAvailability"}
<New
{wallet}
name={activeRoute.params.view.params.nameOrDomain}
owner={activeRoute.params.view.params.owner} />
{:else if activeRoute.params.view.resource === "register"}
{#if session}
<Submit
{wallet}
name={activeRoute.params.view.params.nameOrDomain}
owner={activeRoute.params.view.params.owner}
{session} />
{:else}
<ErrorModal
message={"You must connect your wallet to register"}
on:close={() => {
router.push({
resource: "registrations",
params: { view: { resource: "validateName" } },
});
}} />
{/if}
{:else if activeRoute.params.view.resource === "view"}
<View
{wallet}
retry={activeRoute.params.view.params.retry}
domain={activeRoute.params.view.params.nameOrDomain} />
{:else}
{unreachable(activeRoute.params.view)}
{/if}
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import type { Session } from "@app/lib/session";
import type { Wallet } from "@app/lib/wallet";
import { onMount } from "svelte";
import * as router from "@app/lib/router";
import BlockTimer from "@app/views/registrations/BlockTimer.svelte";
import Button from "@app/components/Button.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Loading from "@app/components/Loading.svelte";
import Modal from "@app/components/Modal.svelte";
import { registerName, State, state } from "@app/lib/registrar";
import { twemoji } from "@app/lib/utils";
export let wallet: Wallet;
export let name: string;
export let owner: string | null;
export let session: Session;
let error: Error | null = null;
const registrationOwner = owner || session.address;
function view() {
router.push({
resource: "registrations",
params: {
view: {
resource: "view",
params: {
nameOrDomain: `${name}.${wallet.registrar.domain}`,
retry: true,
},
},
},
});
}
onMount(async () => {
try {
await registerName(name, registrationOwner, wallet);
} catch (e: any) {
console.error("Error", e);
state.set({ connection: State.Failed });
error = e;
}
});
let latestBlock: number;
wallet.provider.on("block", (block: number) => {
latestBlock = block;
});
</script>
<style>
.loader {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
<svelte:head>
<title>{name}</title>
</svelte:head>
{#if error}
<ErrorModal
title="Transaction failed"
message={error.message}
on:close={() =>
router.push({
resource: "registrations",
params: { view: { resource: "validateName" } },
})} />
{:else}
<Modal>
<span slot="title" use:twemoji>
{#if $state.connection === State.Registered}
<div>🎉</div>
{:else}
<div>🌐</div>
{/if}
{name}.{wallet.registrar.domain}
</span>
<span slot="subtitle">
{#if $state.connection === State.Connecting}
Connecting…
{:else if $state.connection === State.SigningPermit}
Approving registration fee. Please confirm in your wallet.
{:else if $state.connection === State.SigningCommit}
Committing to <span class="txt-bold">{name}</span>
. Please confirm transaction in your wallet.
{:else if $state.connection === State.Committing}
Waiting for <span class="txt-bold">commit</span>
transaction to be processed&hellip;
{:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
Waiting for commitment to mature. This may take a moment.
{:else if $state.connection === State.SigningRegister}
Proceeding with registration. Please confirm transaction in your wallet.
{:else if $state.connection === State.Registering}
Waiting for <span class="txt-bold">register</span>
transaction to be processed&hellip;
{/if}
</span>
<span slot="body" class="loader">
{#if $state.connection === State.Registered}
This name has been successfully registered to
<span class="txt-highlight">{registrationOwner}</span>
{:else if $state.connection === State.WaitingToRegister && $state.commitmentBlock}
<BlockTimer
{latestBlock}
startBlock={$state.commitmentBlock}
duration={$state.minAge} />
{:else}
<Loading small center />
{/if}
</span>
<span slot="actions">
{#if $state.connection === State.Registered}
<Button on:click={view} variant="foreground">View</Button>
{/if}
</span>
</Modal>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts" strictEvents>
import type { EnsRecord } from "@app/lib/resolver";
import type { Registration } from "@app/lib/registrar";
import type { Wallet } from "@app/lib/wallet";
import type { State } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Loading from "@app/components/Loading.svelte";
import Modal from "@app/components/Modal.svelte";
import { Status } from "@app/lib/utils";
import { onMount, createEventDispatcher } from "svelte";
import { setRecords } from "@app/lib/resolver";
import { twemoji } from "@app/lib/utils";
export let domain: string;
export let wallet: Wallet;
export let records: EnsRecord[];
export let registration: Registration;
const dispatch = createEventDispatcher<{ close: never }>();
let state: State = {
status: Status.Failed,
error: "Error registering, something happened.",
};
onMount(async () => {
try {
state.status = Status.Signing;
const tx = await setRecords(
domain,
records,
registration.resolver,
wallet,
);
state.status = Status.Pending;
await tx.wait();
state.status = Status.Success;
} catch (e: any) {
console.error(e);
state = { status: Status.Failed, error: e.message };
}
});
const onDone = () => {
// Reload page to load updates to the registration.
location.reload();
};
const onClose = () => {
dispatch("close");
};
</script>
<Modal floating error={state.status === Status.Failed}>
<span slot="title">
<div use:twemoji>🧾</div>
<div>Update registration</div>
</span>
<span slot="subtitle">
{#if state.status === Status.Signing}
<p>Please confirm the transaction in your wallet</p>
{:else if state.status === Status.Pending}
<p>Waiting for transaction to be processed…</p>
{:else if state.status === Status.Success}
<p>Your registration was successfully updated.</p>
{:else if state.status === Status.Failed}
<span class="txt-bold">Error:</span>
{state.error}
{/if}
</span>
<span slot="actions">
{#if [Status.Signing, Status.Pending].includes(state.status)}
<Loading center small />
{:else if state.status === Status.Success}
<Button variant="foreground" on:click={onDone}>Done</Button>
{:else if state.status === Status.Failed}
<Button variant="negative" on:click={onClose}>Close</Button>
{/if}
</span>
</Modal>
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import type { EnsRecord } from "@app/lib/resolver";
import type { Field, RegistrationRecord } from "@app/components/Form.svelte";
import type { Registration } from "@app/lib/registrar";
import type { Wallet } from "@app/lib/wallet";
import type { ethers } from "ethers";
import { onMount } from "svelte";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Form from "@app/components/Form.svelte";
import Link from "@app/components/Link.svelte";
import Loading from "@app/components/Loading.svelte";
import Modal from "@app/components/Modal.svelte";
import Update from "./Update.svelte";
import { assert } from "@app/lib/error";
import { defaultHttpApiPort } from "@app/lib/seed";
import { getRegistration, getOwner } from "@app/lib/registrar";
import { isAddressEqual, isReverseRecordSet, twemoji } from "@app/lib/utils";
import { session } from "@app/lib/session";
enum Status {
Loading,
Found,
NotFound,
Failed,
}
type State =
| { status: Status.Loading }
| { status: Status.NotFound }
| { status: Status.Found; registration: Registration; owner: string }
| { status: Status.Failed; error: string };
export let domain: string;
export let wallet: Wallet;
export let retry: boolean;
domain = domain.toLowerCase();
let state: State = { status: Status.Loading };
let editable = false;
let fields: Field[] = [];
let updateRecords: EnsRecord[] | null = null;
let retries = 3;
let resolver: ethers.providers.EnsResolver | undefined = undefined;
async function parseRecords(
r: Registration | null,
): Promise<Registration | null> {
if (r) {
let reverseRecord = false;
if (r.profile.address) {
reverseRecord = await isReverseRecordSet(
r.profile.address,
domain,
wallet,
);
}
const owner = await getOwner(domain, wallet);
resolver = r.resolver;
fields = [
{
name: "owner",
validate: "address",
placeholder: "",
description: "The owner and controller of this name.",
value: owner,
resolve: true,
editable: false,
},
{
name: "address",
validate: "address",
placeholder: "Ethereum address, eg. 0x4a9cf21…bc91",
description:
"The address this name resolves to. " +
(reverseRecord
? `The reverse record for this address is set to **${domain}**`
: "The reverse record for this address is **not set**. " +
"For this name to be correctly associated with the address, " +
"a reverse record should be set."),
value: r.profile.address ?? "",
editable: true,
},
{
name: "url",
label: "URL",
validate: "URL",
placeholder: "https://acme.org",
description: "A homepage or other URL associated with this name.",
value: r.profile.url ?? "",
editable: true,
},
{
name: "avatar",
validate: "URL",
placeholder: "https://acme.org/avatar.png",
description: "An avatar or square image associated with this name.",
value: r.profile.avatar ?? "",
editable: true,
},
{
name: "twitter",
validate: "handle",
placeholder: "Twitter username, eg. 'acme'",
description: "The Twitter handle associated with this name.",
value: r.profile.twitter ?? "",
editable: true,
},
{
name: "github",
validate: "handle",
label: "GitHub",
placeholder: "GitHub username, eg. 'acme'",
description: "The GitHub username associated with this name.",
value: r.profile.github ?? "",
editable: true,
},
{
name: "id",
label: "Radicle",
validate: "identity",
placeholder: "Radicle URN, eg. rad:git:hnrkqdpm9ub19oc8d…",
description: "The local radicle identity associated with this name.",
value: r.profile.id ?? "",
editable: true,
},
{
name: "seed.host",
label: "Seed Host",
validate: "domain",
placeholder: "seed.acme.org",
url: r.profile.seed && `/seeds/${r.profile.seed.host}`,
description:
"The seed host address. " +
"Only domain names with TLS are supported. " +
`HTTP(S) API requests use port ${defaultHttpApiPort}.`,
value: r.profile.seed?.host ?? "",
editable: true,
},
{
name: "seed.id",
label: "Seed ID",
validate: "id",
placeholder: "hynkyndc6w3p8urucakobzncqny7xxtw88…",
description:
"The Device ID of a Radicle Link node that hosts entities associated with this name.",
value: r.profile.seed?.id ?? "",
editable: true,
},
];
state = { status: Status.Found, registration: r, owner };
} else {
state = { status: Status.NotFound };
}
if (retry) retries -= 1;
return r;
}
onMount(() => {
getRegistration(domain, wallet, resolver)
.then(parseRecords)
.catch(err => {
state = { status: Status.Failed, error: err };
});
});
const onSave = async (event: { detail: RegistrationRecord[] }) => {
assert(state.status === Status.Found, "registration must be found");
updateRecords = event.detail.map(f => {
return { name: f.name, value: f.value };
});
};
$: if (retry && state.status === Status.NotFound && retries > 0) {
getRegistration(domain, wallet, resolver)
.then(parseRecords)
.catch(err => {
state = { status: Status.Failed, error: err };
});
}
$: isOwner = (owner: string): boolean => {
return $session ? isAddressEqual(owner, $session.address) : false;
};
</script>
<style>
main {
padding: 5rem 0;
}
main > header {
display: flex;
align-items: center;
justify-content: left;
margin-bottom: 2rem;
}
.register {
color: var(--color-primary);
border-bottom-color: var(--color-primary-5);
}
.register:hover {
color: var(--color-primary-5);
border-bottom-color: var(--color-primary-5);
}
main > header > * {
margin: 0 1rem 0 0;
}
@media (max-width: 720px) {
main {
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
<svelte:head>
<title>{domain}</title>
</svelte:head>
{#if state.status === Status.Loading}
<Loading />
{:else if state.status === Status.Failed}
<ErrorModal
title="Registration could not be loaded"
on:close={() =>
router.push({
resource: "registrations",
params: { view: { resource: "validateName" } },
})}>
{state.error}
</ErrorModal>
{:else if state.status === Status.NotFound}
<Modal subtle>
<span slot="title" class="txt-highlight">
<div use:twemoji>🍄</div>
{domain}
</span>
<span slot="body">
<p>
The name <span class="txt-bold">{domain}</span>
is not registered.
</p>
</span>
<span slot="actions">
<Link
route={{
resource: "registrations",
params: {
view: {
resource: "register",
params: { nameOrDomain: domain, owner: null },
},
},
}}>
<span class="txt-link register">Register &rarr;</span>
</Link>
</span>
</Modal>
{:else if state.status === Status.Found}
<main>
<header>
<div class="txt-title txt-bold">{domain}</div>
<div style="width: 4rem;">
{#if !editable}
<Button
size="small"
variant="primary"
disabled={!isOwner(state.owner)}
title={!isOwner(state.owner)
? "Only owner can edit this profile"
: ""}
on:click={() => (editable = !editable)}>
Edit
</Button>
{/if}
</div>
</header>
<Form
{wallet}
{editable}
{fields}
on:save={onSave}
on:cancel={() => (editable = false)} />
</main>
{#if updateRecords}
<Update
{wallet}
{domain}
on:close={() => (updateRecords = null)}
registration={state.registration}
records={updateRecords} />
{/if}
{/if}
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import View from "@app/views/seeds/View.svelte";
export let host: string;
</script>
{#if host === "radicle.local"}
<View hostAndPort={"0.0.0.0"} />
{:else}
<View hostAndPort={host} />
{/if}
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import type { Stats } from "@app/lib/seed";
import type { ProjectInfo } from "@app/lib/project";
import { formatSeedId, formatSeedHost, twemoji } from "@app/lib/utils";
import { Seed } from "@app/lib/seed";
import Loading from "@app/components/Loading.svelte";
import SeedAddress from "@app/components/SeedAddress.svelte";
import NotFound from "@app/components/NotFound.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
import Projects from "@app/views/seeds/View/Projects.svelte";
import Async from "@app/components/Async.svelte";
import { Project } from "@app/lib/project";
import type { Host } from "@app/lib/api";
export let hostAndPort: string;
const [host, port] = hostAndPort.includes(":")
? hostAndPort.split(":")
: [hostAndPort, 8777];
const hostName = formatSeedHost(host);
const seedHost: Host = { host, port: Number(port) };
const getProjectsAndStats = async (
seed: Seed,
): Promise<{
stats: Stats;
projects: ProjectInfo[];
}> => {
const stats = await seed.getStats();
const projects = await Project.getProjects(seedHost, { perPage: 10 });
return { stats, projects };
};
</script>
<style>
main {
padding: 5rem 0;
width: 720px;
}
main > header {
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
}
.fields {
display: grid;
grid-template-columns: 5rem 4fr 2fr;
gap: 1rem 2rem;
margin-bottom: 2rem;
}
.fields > div {
place-self: center start;
height: 2rem;
line-height: 2rem;
}
.title {
display: flex;
align-items: center;
}
.seed-label {
display: flex;
align-items: center;
}
@media (max-width: 720px) {
main {
width: 100%;
padding: 1.5rem;
}
.fields {
grid-template-columns: 5rem auto;
}
}
</style>
<svelte:head>
<title>{hostName}</title>
</svelte:head>
{#await Seed.lookup(host, Number(port))}
<main class="layout-centered">
<Loading center />
</main>
{:then seed}
<main>
<header>
<span class="title txt-title">
<span class="txt-bold">
{hostName}
<span class="layout-desktop-inline" use:twemoji>{seed.emoji}</span>
</span>
</span>
</header>
<div class="fields">
<!-- Seed Address -->
<div class="txt-highlight">Address</div>
<SeedAddress {seed} port={seed.link.port} />
<!-- Seed ID -->
<div class="txt-highlight">Seed ID</div>
<div class="seed-label">
{formatSeedId(seed.id)}
<Clipboard small text={seed.id} />
</div>
<div class="layout-desktop" />
<!-- API Port -->
<div class="txt-highlight">API Port</div>
<div>{port}</div>
<div class="layout-desktop" />
<!-- API Version -->
<div class="txt-highlight">Version</div>
<div>{seed.version}</div>
<div class="layout-desktop" />
</div>
<!-- Seed Projects -->
<Async fetch={getProjectsAndStats(seed)} let:result>
<Projects {seed} projects={result.projects} stats={result.stats} />
</Async>
</main>
{:catch}
<NotFound
title={host}
subtitle="Not able to query information from this seed." />
{/await}
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import type { Profile } from "@app/lib/profile";
import type { ProjectInfo } from "@app/lib/project";
import type { Seed, Stats } from "@app/lib/seed";
import * as proj from "@app/lib/project";
import * as router from "@app/lib/router";
import List from "@app/components/List.svelte";
import Widget from "@app/views/projects/Widget.svelte";
export let seed: Seed;
export let profile: Profile | null = null;
export let projects: proj.ProjectInfo[];
export let stats: Stats;
// A pointer to the current page of projects added to the listing
let page = 0;
const fetchMoreProjects = async (): Promise<proj.ProjectInfo[]> => {
try {
stats = await seed.getStats();
const projects = await proj.Project.getProjects(seed.api, {
perPage: 10,
page: (page += 1),
});
if (projects.length > 0) {
return projects;
}
} catch (e) {
console.error(e);
}
// We return an empty array, for when no more projects are found, or an error is thrown
// since List is looking for an iterable.
return [];
};
const onClick = (project: ProjectInfo) => {
router.push({
resource: "projects",
params: {
view: { resource: "tree" },
urn: project.urn,
seed: seed.api.port
? `${seed.api.host}:${seed.api.port}`
: seed.api.host,
profile: profile?.name ?? profile?.address,
revision: project.head ?? undefined,
hash: undefined,
search: undefined,
},
});
};
</script>
<style>
.projects {
margin-top: 1rem;
}
.projects .project {
margin-bottom: 0.5rem;
}
</style>
<div class="projects">
<List
bind:items={projects}
complete={projects.length === stats.projects.count}
query={fetchMoreProjects}>
<svelte:fragment slot="list" let:items>
{#each items as project}
{#if project.head}
<div class="project">
<Widget {project} {seed} on:click={() => onClick(project)} />
</div>
{/if}
{/each}
</svelte:fragment>
</List>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import * as utils from "@app/lib/utils";
import * as router from "@app/lib/router";
import Button from "@app/components/Button.svelte";
import TextInput from "@app/components/TextInput.svelte";
import { state, getInfo } from "@app/lib/vesting";
export let wallet: Wallet;
let contractAddress = "";
$: valid = utils.isAddress(contractAddress);
$: validationMessage =
contractAddress !== "" && !valid
? "Please enter a valid Ethereum address."
: "";
const loadContract = async () => {
state.set("loading");
try {
const info = await getInfo(contractAddress, wallet);
router.push({
resource: "vesting",
params: {
view: {
resource: "view",
params: { contract: contractAddress, info },
},
},
});
} catch (error) {
validationMessage =
"Couldn't load contract, check dev console for details.";
console.error(error);
}
state.set("idle");
};
</script>
<style>
main {
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 38rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.form {
display: flex;
gap: 1rem;
}
</style>
<svelte:head>
<title>Radicle &ndash; Vesting</title>
</svelte:head>
<main>
<div class="title">
Your Radicle <span class="txt-bold">vesting contract</span>
</div>
<div class="form">
<TextInput
autofocus
placeholder="Enter vesting contract address"
{valid}
{validationMessage}
loading={$state === "loading"}
disabled={$state === "loading"}
on:submit={loadContract}
bind:value={contractAddress} />
<Button
on:click={loadContract}
variant="primary"
waiting={$state === "loading"}
disabled={!valid || $state === "loading"}>
Load
</Button>
</div>
</main>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Wallet } from "@app/lib/wallet";
import type { VestingRoute } from "@app/lib/router/definitions";
import type { Session } from "@app/lib/session";
import Form from "./Form.svelte";
import View from "./View.svelte";
export let activeRoute: VestingRoute;
export let wallet: Wallet;
export let session: Session | null;
</script>
{#if activeRoute.params.view.resource === "form"}
<Form {wallet} />
{:else if activeRoute.params.view.resource === "view"}
<View
{wallet}
{session}
info={activeRoute.params.view.params.info}
contractAddress={activeRoute.params.view.params.contract} />
{/if}
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import type { Session } from "@app/lib/session";
import type { Wallet } from "@app/lib/wallet";
import type { VestingInfo } from "@app/lib/vesting";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import Address from "@app/components/Address.svelte";
import Button from "@app/components/Button.svelte";
import Modal from "@app/components/Modal.svelte";
import { state, getInfo, withdrawVested } from "@app/lib/vesting";
import { onMount } from "svelte";
import ErrorModal from "@app/components/ErrorModal.svelte";
import Loading from "@app/components/Loading.svelte";
export let contractAddress: string;
export let info: VestingInfo | null = null;
export let session: Session | null;
export let wallet: Wallet;
let error: Error | undefined = undefined;
onMount(async () => {
if (!info) {
state.set("loading");
try {
info = await getInfo(contractAddress, wallet);
} catch (e) {
error = e as Error;
}
}
state.set("idle");
});
const parseVestingPeriods = (input: string[]): string => {
const total = input
.map(s => parseInt(s))
.reduce((prev, curr) => prev + curr, 0);
return new Date(total * 1000).toDateString();
};
</script>
<style>
table {
table-layout: fixed;
border-collapse: separate;
border-spacing: 2rem 0;
}
td {
text-align: left;
text-overflow: ellipsis;
}
</style>
<svelte:head>
<title>Radicle &ndash; Vesting</title>
</svelte:head>
{#if error}
<ErrorModal
title="Failed to obtain contract information"
message={error.message}
on:close={() => router.pop()} />
{:else if $state === "loading"}
<Loading center />
{:else if info}
{@const isBeneficiary =
session && utils.isAddressEqual(info.beneficiary, session.address)}
<Modal>
<span slot="title">
{contractAddress}
</span>
<span slot="body">
{#if $state === "withdrawn"}
Tokens successfully withdrawn to {utils.formatAddress(
info.beneficiary,
)}.
{:else}
<table>
<tr>
<td class="txt-highlight">Beneficiary</td>
<td>
<Address {wallet} address={info.beneficiary} compact resolve />
</td>
</tr>
<tr>
<td class="txt-highlight">Allocation</td>
<td>
{info.totalVesting}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="txt-highlight">Withdrawn</td>
<td>
{info.withdrawn}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="txt-highlight">Withdrawable</td>
<td>
{info.withdrawableBalance}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="txt-highlight">Start Time</td>
<td>
<span class="txt-bold">
{parseVestingPeriods([info.vestingStartTime])}
</span>
</td>
</tr>
<tr>
<td class="txt-highlight">Cliff Period End</td>
<td>
<span class="txt-bold">
{parseVestingPeriods([info.vestingStartTime, info.cliffPeriod])}
</span>
</td>
</tr>
<tr>
<td class="txt-highlight">Vesting Period End</td>
<td>
<span class="txt-bold">
{parseVestingPeriods([
info.vestingStartTime,
info.vestingPeriod,
])}
</span>
</td>
</tr>
</table>
{/if}
</span>
<span slot="actions">
{#if isBeneficiary}
{#if $state === "withdrawingSign"}
<Button disabled waiting={true} variant="primary">
Waiting for signature…
</Button>
{:else if $state === "withdrawing"}
<Button disabled waiting={true} variant="primary">
Withdrawing…
</Button>
{:else if $state === "idle"}
<Button
on:click={() => withdrawVested(contractAddress, wallet)}
variant="primary">
Withdraw
</Button>
{/if}
{/if}
<Button on:click={() => router.pop()} variant="primary">Back</Button>
</span>
</Modal>
{/if}