mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-14 02:50:51 +08:00
Organise files more consistently
Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
@@ -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 – 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>
|
||||
​.
|
||||
</div>
|
||||
{:else if !$session}
|
||||
<div class="subtitle">
|
||||
To get RAD tokens on <span class="txt-bold">
|
||||
{capitalize(wallet.network.name)}
|
||||
</span>
|
||||
​,
|
||||
<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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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 – 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>
|
||||
@@ -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}
|
||||
@@ -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 →
|
||||
</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}
|
||||
@@ -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>
|
||||
​
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 – 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>
|
||||
@@ -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 →
|
||||
</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>
|
||||
@@ -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}
|
||||
@@ -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…
|
||||
{: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…
|
||||
{/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}
|
||||
@@ -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>
|
||||
@@ -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 →</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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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 – 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>
|
||||
@@ -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}
|
||||
@@ -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 – 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}
|
||||
Reference in New Issue
Block a user