Add user page

This commit is contained in:
Sebastian Martinez
2024-07-31 12:33:05 -03:00
parent ab71fdcc1d
commit a1dc7fc2ef
32 changed files with 821 additions and 53 deletions
+40 -1
View File
@@ -97,6 +97,17 @@ const nodeSchema = object({
description: string().optional(),
});
export type NodeIdentity = z.infer<typeof nodeIdentitySchema>;
const nodeIdentitySchema = object({
alias: string().nullable(),
did: string(),
ssh: object({
full: string(),
hash: string(),
}),
});
export type NodeInfo = z.infer<typeof nodeInfoSchema>;
const nodeInfoSchema = object({
@@ -202,7 +213,7 @@ export class HttpdClient {
);
}
public async getPoliciesById(
public async getPolicyById(
id: string,
options?: RequestOptions,
): Promise<SeedingPolicy> {
@@ -226,4 +237,32 @@ export class HttpdClient {
nodeSchema,
);
}
public async getNodeIdentity(
id: string,
options?: RequestOptions,
): Promise<NodeIdentity> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `nodes/${id}`,
options,
},
nodeIdentitySchema,
);
}
public async getNodeInventory(
id: string,
options?: RequestOptions,
): Promise<string[]> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `nodes/${id}/inventory`,
options,
},
array(string()),
);
}
}
+2
View File
@@ -130,12 +130,14 @@ export class Client {
public async getByDelegate(
delegateId: string,
query?: ProjectListQuery,
options?: RequestOptions,
): Promise<Project[]> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `delegates/${delegateId}/projects`,
query,
options,
},
projectsSchema,
+3
View File
@@ -19,6 +19,7 @@
import Patch from "@app/views/projects/Patch.svelte";
import Patches from "@app/views/projects/Patches.svelte";
import Source from "@app/views/projects/Source.svelte";
import Users from "@app/views/users/View.svelte";
import Error from "@app/views/error/View.svelte";
import Loading from "@app/components/Loading.svelte";
@@ -60,6 +61,8 @@
</div>
{:else if $activeRouteStore.resource === "nodes"}
<Nodes {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "users"}
<Users {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.source"}
<Source {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.history"}
+10 -10
View File
@@ -2,7 +2,7 @@
import { createIcon } from "@app/lib/blockies";
export let nodeId: string;
export let inline = false;
export let variant: "small" | "large";
function createContainer(source: string) {
source = source.replace("did:key:", "");
@@ -19,20 +19,19 @@
<style>
.avatar {
display: block;
border-radius: var(--border-radius-round);
box-shadow: 0 0 0 1px var(--color-border-match-background);
min-width: 1rem;
min-height: 1rem;
height: 100%;
width: inherit;
object-fit: cover;
background-size: cover;
background-repeat: no-repeat;
}
.inline {
display: inline-block !important;
.small {
width: 1rem;
height: 1rem;
border-radius: var(--border-radius-round);
}
.large {
width: 4rem;
border-radius: var(--border-radius-small);
}
</style>
@@ -40,5 +39,6 @@
title={nodeId}
src={createContainer(nodeId)}
class="avatar"
alt="avatar"
class:inline />
class:small={variant === "small"}
class:large={variant === "large"}
alt="avatar" />
+3 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Comment } from "@http-client";
import type { BaseUrl, Comment } from "@http-client";
import * as utils from "@app/lib/utils";
@@ -11,6 +11,7 @@
export let id: string;
export let authorId: string;
export let authorAlias: string | undefined = undefined;
export let baseUrl: BaseUrl;
export let body: string;
export let reactions: Comment["reactions"] | undefined = undefined;
export let caption = "commented";
@@ -107,7 +108,7 @@
{/if}
<div class="card-header" class:card-header-no-icon={isReply}>
<slot class="icon" name="icon" />
<NodeId nodeId={authorId} alias={authorAlias} />
<NodeId {baseUrl} nodeId={authorId} alias={authorAlias} />
<slot name="caption">{caption}</slot>
<Id {id} />
<span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
+2 -1
View File
@@ -13,6 +13,7 @@
export let shorten: boolean = true;
export let style: "oid" | "commit" | "none" = "oid";
export let ariaLabel: string | undefined = undefined;
export let styleWidth: string | undefined = undefined;
let icon: ComponentProps<Icon>["name"] = "clipboard";
const text = "Click to copy";
@@ -72,7 +73,7 @@
}
</style>
<div class="container">
<div class="container" style:width={styleWidth}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:mouseenter={() => {
+8 -7
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import type { BaseUrl } from "@http-client";
import { formatNodeId, parseNodeId, truncateId } from "@app/lib/utils";
import Avatar from "./Avatar.svelte";
import Id from "./Id.svelte";
import Link from "./Link.svelte";
export let baseUrl: BaseUrl;
export let nodeId: string;
export let alias: string | undefined = undefined;
</script>
@@ -12,7 +14,6 @@
.avatar-alias {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1rem;
font-family: var(--font-family-monospace);
@@ -24,9 +25,9 @@
}
</style>
<Id id={nodeId} style="none">
<div class="avatar-alias">
<Avatar {nodeId} />
<div class="avatar-alias">
<Avatar variant="small" {nodeId} />
<Link styleHoverState route={{ resource: "users", did: nodeId, baseUrl }}>
{#if alias}
<span class="txt-overflow">
{alias}
@@ -39,5 +40,5 @@
{truncateId(parseNodeId(nodeId)?.pubkey || "")}
</span>
{/if}
</div>
</Id>
</Link>
</div>
+1 -1
View File
@@ -14,7 +14,6 @@
import Link from "@app/components/Link.svelte";
export let compact = false;
export let projectInfo: ProjectInfo;
$: project = projectInfo.project;
@@ -155,6 +154,7 @@
<Icon name="lock" />
</div>
{/if}
<slot name="delegate" />
<Badge
variant="neutral"
size="tiny"
+8 -1
View File
@@ -18,9 +18,16 @@ export interface ProjectInfo {
export async function fetchProjectInfos(
baseUrl: BaseUrl,
query?: ProjectListQuery,
delegate?: string,
): Promise<ProjectInfo[]> {
const api = new HttpdClient(baseUrl);
const projects = await api.project.getAll(query);
let projects: Project[];
if (delegate) {
projects = await api.project.getByDelegate(delegate, query);
} else {
projects = await api.project.getAll(query);
}
const info = await Promise.all(
projects.map(async project => {
const [activity, lastCommit] = await Promise.all([
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Comment } from "@http-client";
import type { BaseUrl, Comment } from "@http-client";
import CommentComponent from "@app/components/Comment.svelte";
import Icon from "./Icon.svelte";
@@ -9,6 +9,7 @@
replies: Comment[];
};
export let rawPath: string;
export let baseUrl: BaseUrl;
$: root = thread.root;
$: replies = thread.replies;
@@ -43,6 +44,7 @@
<div class="comments">
<div class="top-level-comment" class:has-replies={replies.length > 0}>
<CommentComponent
{baseUrl}
{rawPath}
id={root.id}
lastEdit={root.edits.length > 1 ? root.edits.pop() : undefined}
@@ -58,6 +60,7 @@
<div class="replies">
{#each replies as reply}
<CommentComponent
{baseUrl}
{rawPath}
lastEdit={reply.edits.length > 1 ? reply.edits.pop() : undefined}
id={reply.id}
+12 -1
View File
@@ -13,6 +13,7 @@ import {
} from "@app/views/projects/router";
import { loadRoute } from "@app/lib/router/definitions";
import { nodePath } from "@app/views/nodes/router";
import { userRouteToPath, userTitle } from "@app/views/users/router";
export { type Route };
@@ -112,6 +113,8 @@ function setTitle(loadedRoute: LoadedRoute) {
} else if (loadedRoute.resource === "error") {
title.push("Error");
title.push("Radicle");
} else if (loadedRoute.resource === "users") {
title.push(...userTitle(loadedRoute));
} else if (loadedRoute.resource === "notFound") {
title.push("Page not found");
title.push("Radicle");
@@ -186,7 +189,13 @@ function urlToRoute(url: URL): Route | null {
if (hostAndPort) {
const baseUrl = extractBaseUrl(hostAndPort);
const id = segments.shift();
if (id) {
if (id === "users") {
const did = segments.shift();
if (did) {
return { resource: "users", baseUrl, did };
}
return null;
} else if (id) {
return resolveProjectRoute(baseUrl, id, segments, url.search);
} else {
return {
@@ -217,6 +226,8 @@ export function routeToPath(route: Route): string {
} else {
return nodePath(route.params.baseUrl);
}
} else if (route.resource === "users") {
return userRouteToPath(route);
} else if (
route.resource === "project.source" ||
route.resource === "project.history" ||
+6
View File
@@ -6,11 +6,13 @@ import type {
ProjectLoadedRoute,
ProjectRoute,
} from "@app/views/projects/router";
import type { UserLoadedRoute, UserRoute } from "@app/views/users/router";
import type { NodesRoute, NodesLoadedRoute } from "@app/views/nodes/router";
import type { ComponentProps } from "svelte";
import type IconLarge from "@app/components/IconLarge.svelte";
import { loadProjectRoute } from "@app/views/projects/router";
import { loadUserRoute } from "@app/views/users/router";
import { loadNodeRoute } from "@app/views/nodes/router";
interface BootingRoute {
@@ -36,6 +38,7 @@ export interface ErrorRoute {
export type Route =
| BootingRoute
| UserRoute
| ErrorRoute
| NotFoundRoute
| ProjectRoute
@@ -43,6 +46,7 @@ export type Route =
export type LoadedRoute =
| BootingRoute
| UserLoadedRoute
| ErrorRoute
| NotFoundRoute
| ProjectLoadedRoute
@@ -54,6 +58,8 @@ export async function loadRoute(
): Promise<LoadedRoute> {
if (route.resource === "nodes") {
return await loadNodeRoute(route.params);
} else if (route.resource === "users") {
return await loadUserRoute(route);
} else if (
route.resource === "project.source" ||
route.resource === "project.history" ||
+10
View File
@@ -89,6 +89,16 @@ export function formatEditedCaption(
} edited ${absoluteTimestamp(timestamp)}`;
}
export function formatDid({
prefix,
pubkey,
}: {
prefix: string;
pubkey: string;
}): string {
return `${prefix}${pubkey}`;
}
export function baseUrlToString(baseUrl: BaseUrl): string {
return `${baseUrl.scheme}://${baseUrl.hostname}:${baseUrl.port}`;
}
+1 -1
View File
@@ -458,7 +458,7 @@
styleWidth="100%"
let:toggle
on:click={toggle}>
<Icon name="seedling" />
<Icon name="info" />
</Button>
<div slot="popover" style:width="20rem">
+1 -1
View File
@@ -57,7 +57,7 @@
{#each assignees as { id }}
<Badge variant="neutral" size="small">
<div class="assignee">
<Avatar inline nodeId={id} />
<Avatar variant="small" nodeId={id} />
<span>{formatNodeId(id)}</span>
</div>
</Badge>
+6 -1
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import type { BaseUrl } from "@http-client";
import type { PatchReviews } from "../Patch.svelte";
import Icon from "@app/components/Icon.svelte";
import NodeId from "@app/components/NodeId.svelte";
export let baseUrl: BaseUrl;
export let reviews: PatchReviews;
</script>
@@ -69,7 +71,10 @@
<Icon name="chat" />
{/if}
</span>
<NodeId nodeId={review.author.id} alias={review.author.alias} />
<NodeId
{baseUrl}
nodeId={review.author.id}
alias={review.author.alias} />
</div>
{:else}
<div class="txt-missing no-reviews">No reviews</div>
+10 -2
View File
@@ -378,7 +378,10 @@
style:padding="0 0.375rem">
<Icon name="patch" />
</div>
<NodeId nodeId={revisionAuthor.id} alias={revisionAuthor.alias} />
<NodeId
{baseUrl}
nodeId={revisionAuthor.id}
alias={revisionAuthor.alias} />
{#if patchId === revisionId}
opened this patch on base
<Id id={revisionBase} style="commit" />
@@ -455,7 +458,10 @@
{#each timelines as element}
{#if element.type === "thread"}
<div class="connector" />
<Thread thread={element.inner} rawPath={rawPath(revisionBase)} />
<Thread
{baseUrl}
thread={element.inner}
rawPath={rawPath(revisionBase)} />
{:else if element.type === "merge"}
<div class="connector" />
<div class="action merge">
@@ -465,6 +471,7 @@
</div>
<NodeId
{baseUrl}
nodeId={element.inner.author.id}
alias={element.inner.author.alias}>
</NodeId>
@@ -489,6 +496,7 @@
class:positive-review={review.verdict === "accept"}
class:negative-review={review.verdict === "reject"}>
<CommentComponent
{baseUrl}
id={review.id}
rawPath={rawPath(revisionBase)}
authorId={author}
+8 -2
View File
@@ -181,7 +181,10 @@
{issue.state.reason}
</Badge>
{/if}
<NodeId nodeId={issue.author.id} alias={issue.author.alias} />
<NodeId
{baseUrl}
nodeId={issue.author.id}
alias={issue.author.alias} />
opened
<Id id={issue.id} />
<span title={utils.absoluteTimestamp(issue.discussion[0].timestamp)}>
@@ -228,7 +231,10 @@
<div class="connector" />
<div class="threads">
{#each threads as thread, i (thread.root.id)}
<ThreadComponent {thread} rawPath={rawPath(project.head)} />
<ThreadComponent
{baseUrl}
{thread}
rawPath={rawPath(project.head)} />
{#if i < threads.length - 1}
<div class="connector" />
{/if}
+1 -1
View File
@@ -119,7 +119,7 @@
{/if}
<div
style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<NodeId nodeId={issue.author.id} alias={issue.author.alias} />
<NodeId {baseUrl} nodeId={issue.author.id} alias={issue.author.alias} />
opened
<Id id={issue.id} />
<span title={absoluteTimestamp(issue.discussion[0].timestamp)}>
+1 -1
View File
@@ -126,7 +126,7 @@
<Separator />
<span class="breadcrumb">
<span class="breadcrumb" title={project.id}>
<Link
route={{
resource: "project.source",
+6 -3
View File
@@ -355,7 +355,10 @@
insertions={stats.insertions}
deletions={stats.deletions} />
</Link>
<NodeId nodeId={patch.author.id} alias={patch.author.alias} />
<NodeId
{baseUrl}
nodeId={patch.author.id}
alias={patch.author.alias} />
opened
<Id id={patch.id} />
<span title={utils.absoluteTimestamp(patch.revisions[0].timestamp)}>
@@ -376,7 +379,7 @@
<div
style:margin-top="2rem"
style="display: flex; flex-direction: column; gap: 0.5rem;">
<Reviews {reviews} />
<Reviews {baseUrl} {reviews} />
<Labels labels={patch.labels} />
<Embeds embeds={uniqueEmbeds} />
</div>
@@ -500,7 +503,7 @@
</div>
<div class="metadata global-hide-on-medium-desktop-down">
<Reviews {reviews} />
<Reviews {baseUrl} {reviews} />
<Labels labels={patch.labels} />
<Embeds embeds={uniqueEmbeds} />
</div>
+4 -1
View File
@@ -133,7 +133,10 @@
{/if}
<div
style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<NodeId nodeId={patch.author.id} alias={patch.author.alias} />
<NodeId
{baseUrl}
nodeId={patch.author.id}
alias={patch.author.alias} />
{patch.revisions.length > 1 ? "updated" : "opened"}
<Id id={patch.id} />
{#if patch.revisions.length > 1}
+2
View File
@@ -240,6 +240,7 @@
<div class="bottom">
<div class="repo box" class:expanded>
<ContextRepo
{baseUrl}
projectThreshold={project.threshold}
projectDelegates={project.delegates}
{seedingPolicy} />
@@ -286,6 +287,7 @@
<div slot="popover" class="txt-small" style:width="18rem">
<ContextRepo
{baseUrl}
projectThreshold={project.threshold}
projectDelegates={project.delegates}
{seedingPolicy} />
+10 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Project, SeedingPolicy } from "@http-client";
import type { BaseUrl, Project, SeedingPolicy } from "@http-client";
import { capitalize } from "lodash";
@@ -7,6 +7,7 @@
import Icon from "@app/components/Icon.svelte";
import NodeId from "@app/components/NodeId.svelte";
export let baseUrl: BaseUrl;
export let projectThreshold: number;
export let projectDelegates: Project["delegates"];
export let seedingPolicy: SeedingPolicy;
@@ -30,6 +31,7 @@
margin-bottom: 0;
}
.nid {
height: 21.5px;
margin: 0.5rem 0;
}
</style>
@@ -55,11 +57,13 @@
changes to be included in the canonical branch.
{/if}
</div>
{#each projectDelegates as delegate}
<div class="nid">
<NodeId nodeId={delegate.id} alias={delegate.alias} />
</div>
{/each}
<div class="delegates">
{#each projectDelegates as delegate}
<div class="nid">
<NodeId {baseUrl} nodeId={delegate.id} alias={delegate.alias} />
</div>
{/each}
</div>
{/if}
<div class="item-header">
<span style:text-wrap="nowrap">Seeding Scope</span>
@@ -128,7 +128,7 @@
{#if selectedPeer}
<div class="global-flex-item">
<div class="node-id">
<Avatar nodeId={selectedPeer.id} inline />
<Avatar nodeId={selectedPeer.id} variant="small" />
{selectedPeer.alias || formatNodeId(selectedPeer.id)}
</div>
@@ -38,7 +38,10 @@
<IconButton title="Expand peer" on:click={() => (expanded = !expanded)}>
<Icon name={expanded ? "chevron-down" : "chevron-right"} />
</IconButton>
<NodeId nodeId={peer.remote.id} alias={peer.remote.alias} />
<NodeId
baseUrl={baseRoute.node}
nodeId={peer.remote.id}
alias={peer.remote.alias} />
{#if peer.remote.delegate}
<Badge size="tiny" variant="delegate">
<Icon name="badge" />
+7 -7
View File
@@ -274,7 +274,7 @@ export async function loadProjectRoute(
const [project, commit, seedingPolicy, node] = await Promise.all([
api.project.getById(route.project),
api.project.getCommitBySha(route.project, route.commit),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
api.getNode(),
]);
@@ -326,7 +326,7 @@ async function loadPatchesView(
page: 0,
perPage: PATCHES_PER_PAGE,
}),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
api.getNode(),
]);
@@ -356,7 +356,7 @@ async function loadIssuesView(
page: 0,
perPage: ISSUES_PER_PAGE,
}),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
api.getNode(),
]);
@@ -397,7 +397,7 @@ async function loadTreeView(
} else {
projectPromise = api.project.getById(route.project);
peersPromise = api.project.getAllRemotes(route.project);
seedingPolicyPromise = api.getPoliciesById(route.project);
seedingPolicyPromise = api.getPolicyById(route.project);
}
const [project, peers, seedingPolicy, node] = await Promise.all([
@@ -514,7 +514,7 @@ async function loadHistoryView(
const [project, peers, seedingPolicy, branchMap, node] = await Promise.all([
api.project.getById(route.project),
api.project.getAllRemotes(route.project),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
getPeerBranches(api, route.project, route.peer),
api.getNode(),
]);
@@ -572,7 +572,7 @@ async function loadIssueView(
const [project, issue, seedingPolicy, node] = await Promise.all([
api.project.getById(route.project),
api.project.getIssueById(route.project, route.issue),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
api.getNode(),
]);
return {
@@ -600,7 +600,7 @@ async function loadPatchView(
const [project, patch, seedingPolicy, node] = await Promise.all([
api.project.getById(route.project),
api.project.getPatchById(route.project, route.patch),
api.getPoliciesById(route.project),
api.getPolicyById(route.project),
api.getNode(),
]);
const latestRevision = patch.revisions[patch.revisions.length - 1];
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import { formatDid, formatNodeId } from "@app/lib/utils";
import Id from "@app/components/Id.svelte";
export let did: { prefix: string; pubkey: string };
</script>
<div style:word-break="break-word">
<Id styleWidth="fit-content" ariaLabel="node-id" id={formatDid(did)}>
<div class="txt-overflow">
{formatNodeId(did.pubkey)}
</div>
</Id>
</div>
+425
View File
@@ -0,0 +1,425 @@
<script lang="ts">
import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { fetchProjectInfos } from "@app/components/ProjectCard";
import { handleError } from "@app/views/nodes/error";
import Avatar from "@app/components/Avatar.svelte";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import Command from "@app/components/Command.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Help from "@app/App/Help.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import Link from "@app/components/Link.svelte";
import Loading from "@app/components/Loading.svelte";
import MobileFooter from "@app/App/MobileFooter.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import Popover from "@app/components/Popover.svelte";
import ProjectCard from "@app/components/ProjectCard.svelte";
import Separator from "@app/views/projects/Separator.svelte";
import Settings from "@app/App/Settings.svelte";
import UserAddress from "@app/views/users/UserAddress.svelte";
export let baseUrl: BaseUrl;
export let node: NodeIdentity;
export let did: { prefix: string; pubkey: string };
export let nodeAvatarUrl: string | undefined;
export let stats: NodeStats;
</script>
<style>
.layout {
display: grid;
grid-template-rows: 3.5rem 1fr auto;
grid-template-columns: 30rem auto;
grid-template-areas:
"header header"
"sidebar main"
"footer main";
height: 100vh;
}
header {
grid-area: header;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 1rem;
justify-content: space-between;
outline: 1px solid var(--color-fill-separator) !important;
}
.breadcrumbs {
display: flex;
align-items: center;
column-gap: 0.25rem;
line-height: 1rem;
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-small);
white-space: nowrap;
flex-wrap: wrap;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
}
.breadcrumb :global(a:hover) {
color: var(--color-fill-secondary);
}
.sidebar {
grid-area: sidebar;
border-right: 1px solid var(--color-fill-separator);
width: 30rem;
display: flex;
flex-direction: column;
padding: 1.5rem;
z-index: 1;
}
.sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 2rem;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-small);
}
.empty-state,
.loading {
height: 100%;
}
.content {
overflow-y: scroll;
grid-area: main;
}
.wrapper {
height: 100%;
margin: 0 auto;
max-width: 78rem;
padding: 1.5rem;
}
.logo {
height: var(--button-regular-height);
margin: 0 0.5rem;
}
.mobile-footer {
display: none;
}
.footer {
display: flex;
border-right: 1px solid var(--color-fill-separator);
justify-content: space-between;
align-items: center;
padding: 1.5rem;
}
.subtitle {
font-size: var(--font-size-small);
color: var(--color-foreground-dim);
padding: 1rem 0;
}
.avatar {
border-radius: var(--border-radius-tiny);
margin-right: 0.5rem;
}
.user-info {
display: grid;
grid-template-columns: 64px minmax(0, 30rem) max-content;
column-gap: 1rem;
margin-bottom: 1.5rem;
}
.follow-label {
display: block;
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
margin-bottom: 0.75rem;
}
.id {
max-width: 22rem;
}
.repo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
gap: 1rem;
}
@media (max-width: 1010.98px) {
.wrapper {
padding: 1.5rem;
}
.sidebar {
width: 325px;
}
.id {
max-width: 12rem;
}
.layout {
grid-template-columns: 325px auto;
grid-template-areas:
"header header"
"sidebar main"
"footer main";
}
}
@media (max-width: 719.98px) {
.layout {
grid-template-columns: 1fr;
grid-template-areas:
"main"
"main"
"footer";
}
header {
display: none;
}
.content {
overflow-x: hidden;
margin-left: 0;
}
.wrapper {
padding: 1rem;
}
.empty-state,
.loading {
height: calc(100% - 6rem);
}
.footer {
display: none;
}
.mobile-footer {
grid-area: footer;
margin-top: auto;
display: grid;
grid-column: 1 / 4;
}
}
</style>
<div class="layout">
<header>
<div class="breadcrumbs">
<span class="breadcrumb">
<Link
style="display: flex; align-items: center; gap: 0.25rem;"
route={{
resource: "nodes",
params: {
baseUrl,
projectPageIndex: 0,
},
}}>
<img
width="24"
height="24"
class="avatar"
alt="Radicle logo"
src={nodeAvatarUrl
? nodeAvatarUrl
: "/images/default-seed-avatar.png"} />
{baseUrl.hostname}
</Link>
</span>
<Separator />
<span class="breadcrumb">
<Link route={{ resource: "users", did: utils.formatDid(did), baseUrl }}>
<div class="breadcrumb" title={utils.formatDid(did)}>
{node.alias || utils.formatNodeId(did.pubkey)}
</div>
</Link>
</span>
</div>
<Link
style="display: flex; align-items: center;"
route={{ resource: "nodes", params: undefined }}>
<img
width="24"
height="24"
class="logo"
alt="Radicle logo"
src="/radicle.svg" />
</Link>
</header>
<div class="sidebar global-hide-on-mobile-down">
<div class="user-info">
<div style:margin-right="0.5rem">
<Avatar nodeId={did.pubkey} variant="large" />
</div>
<div style:margin-top="0.25rem">
<div class="txt-medium txt-semibold txt-overflow">
{node.alias || utils.formatNodeId(did.pubkey)}
</div>
<div style:margin-top="0.25rem">
<UserAddress {did} />
</div>
</div>
<div class="global-hide-on-small-desktop-down" style="justify-self: end;">
<Popover popoverPositionTop="2.5rem">
<Button
slot="toggle"
let:toggle
on:click={toggle}
variant="secondary">
<div class="global-flex-item">
<Icon name="plus" />
<span>Follow</span>
</div>
</Button>
<div slot="popover" style:width="24rem">
<span class="follow-label">
Use the <ExternalLink href="https://radicle.xyz">
Radicle CLI
</ExternalLink> to start following this user.
</span>
<span class="follow-label">
Following a user ensures that their contributions are fetched onto
your device.
</span>
<Command command={`rad follow ${did.pubkey}`} />
</div>
</Popover>
</div>
</div>
<div style:margin-bottom="1rem">
<div class="sidebar-item txt-small">
<span>SSH Key</span>
<Id id={node.ssh.full}>
<div class="id txt-overflow">{node.ssh.full}</div>
</Id>
</div>
<div class="sidebar-item txt-small">
<span>SSH Hash</span>
<Id id={node.ssh.hash}>
<div class="id txt-overflow">{node.ssh.hash}</div>
</Id>
</div>
</div>
</div>
<div class="footer">
<Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
<Button
variant="outline"
title="Settings"
slot="toggle"
let:toggle
on:click={toggle}>
<Icon name="settings" />
Settings
</Button>
<Settings slot="popover" />
</Popover>
<Popover popoverPositionBottom="2.5rem" popoverPositionLeft="0">
<Button
variant="outline"
title="Help"
slot="toggle"
let:toggle
on:click={toggle}>
<Icon name="help" />
Help
</Button>
<Help slot="popover" />
</Popover>
</div>
<div class="content">
<div class="wrapper">
<div class="global-hide-on-small-desktop-up user-info">
<div style:margin-right="0.5rem">
<Avatar nodeId={did.pubkey} variant="large" />
</div>
<div style:margin-top="0.25rem">
<div class="txt-medium txt-semibold txt-overflow">
{node.alias || utils.formatNodeId(did.pubkey)}
</div>
<div style:margin-top="0.25rem">
<UserAddress {did} />
</div>
</div>
</div>
{#await fetchProjectInfos(baseUrl, { show: "all", perPage: stats.repos.total }, utils.formatDid(did))}
<div class="loading">
<Loading small center />
</div>
{:then repos}
{#if repos.length > 0}
<div class="repo-grid">
{#each repos as projectInfo}
<ProjectCard {projectInfo}>
<svelte:fragment slot="delegate">
<Badge
title={`${node.alias || utils.formatNodeId(did.pubkey)} is a delegate of this repository`}
round
variant="delegate"
size="tiny"
style="padding: 0 0.372rem; gap: 0.125rem;">
<Icon name="badge" />
</Badge>
</svelte:fragment>
</ProjectCard>
{/each}
</div>
<div class="subtitle">
{repos.length}
{repos.length === 1 ? "repository" : "repositories"}
</div>
{:else}
<div class="empty-state">
<Placeholder
iconName="desert"
caption="This user doesn't have any repositories on this node." />
</div>
{/if}
{:catch error}
{router.push(handleError(error, utils.baseUrlToString(baseUrl)))}
{/await}
</div>
</div>
<div class="mobile-footer">
<MobileFooter>
<div style:width="100%">
<Popover popoverPositionBottom="3rem" popoverPositionRight="-7.5rem">
<Button
let:expanded
slot="toggle"
variant={expanded ? "secondary" : "secondary-mobile-toggle"}
styleWidth="100%"
let:toggle
on:click={toggle}>
<Icon name="info" />
</Button>
<div slot="popover" style:width="20rem">
<div class="sidebar-item txt-small">
<span>SSH Key</span>
<Id id={node.ssh.full}>
<div class="id txt-overflow">{node.ssh.full}</div>
</Id>
</div>
<div class="sidebar-item txt-small">
<span>SSH Hash</span>
<Id id={node.ssh.hash}>
<div class="id txt-overflow">{node.ssh.hash}</div>
</Id>
</div>
</div>
</Popover>
</div>
</MobileFooter>
</div>
</div>
+100
View File
@@ -0,0 +1,100 @@
import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import * as utils from "@app/lib/utils";
import config from "virtual:config";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { handleError } from "@app/views/nodes/error";
import { nodePath } from "@app/views/nodes/router";
import { unreachableError } from "@app/views/projects/error";
export interface UserRoute {
resource: "users";
baseUrl: BaseUrl;
did: string;
}
export interface UserLoadedRoute {
resource: "users";
params: {
did: { prefix: string; pubkey: string };
baseUrl: BaseUrl;
node: NodeIdentity;
nodeAvatarUrl: string | undefined;
stats: NodeStats;
};
}
export async function loadUserRoute({
did,
baseUrl,
}: UserRoute): Promise<UserLoadedRoute | NotFoundRoute | ErrorRoute> {
if (
import.meta.env.PROD &&
utils.isLocal(`${baseUrl.hostname}:${baseUrl.port}`)
) {
return {
resource: "error",
params: {
icon: "device",
title: "Local node browsing not supported",
description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
},
};
}
const parsedDid = utils.parseNodeId(did);
if (!parsedDid) {
return {
resource: "error",
params: {
title: "Invalid user DID provided",
description:
"The provided DID is invalid. Please review the identifier for any errors and try again.",
error: new Error(`invalid user DID provided: ${did}`),
},
};
}
const api = new HttpdClient(baseUrl);
try {
const [stats, node, user] = await Promise.all([
api.getStats(),
api.getNode(),
api.getNodeIdentity(parsedDid.pubkey),
]);
return {
resource: "users",
params: {
did: parsedDid,
baseUrl,
node: user,
nodeAvatarUrl: node.avatarUrl,
stats,
},
};
} catch (error) {
console.error(error);
if (
error instanceof Error ||
error instanceof ResponseError ||
error instanceof ResponseParseError
) {
return handleError(error, utils.baseUrlToString(api.baseUrl));
} else {
return unreachableError();
}
}
}
export function userRouteToPath(route: UserRoute): string {
return [nodePath(route.baseUrl), "users", route.did].join("/");
}
export function userTitle(route: UserLoadedRoute): string[] {
if (route.params.node.alias) {
return [route.params.node.alias, utils.formatDid(route.params.did)];
}
return [utils.formatDid(route.params.did)];
}
+55
View File
@@ -0,0 +1,55 @@
import {
test,
expect,
aliceRemote,
bobRemote,
} from "@tests/support/fixtures.js";
import sinon from "sinon";
test("user page", async ({ page }) => {
await page.addInitScript(() => {
sinon.useFakeTimers({
now: new Date("November 24 2022 12:00:00").valueOf(),
shouldClearNativeTimers: true,
shouldAdvanceTime: false,
});
});
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("empty pinned projects", async ({ page }) => {
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("response parse error", async ({ page }) => {
await page.route("*/**/v1/nodes/*", route => {
return route.fulfill({
json: [{ name: 1337 }],
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("response error", async ({ page }) => {
await page.route("*/**/v1/nodes/*", route => {
return route.fulfill({
status: 500,
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
+55
View File
@@ -0,0 +1,55 @@
import {
test,
expect,
aliceRemote,
bobRemote,
} from "@tests/support/fixtures.js";
import sinon from "sinon";
test("user page", async ({ page }) => {
await page.addInitScript(() => {
sinon.useFakeTimers({
now: new Date("November 24 2022 12:00:00").valueOf(),
shouldClearNativeTimers: true,
shouldAdvanceTime: false,
});
});
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("empty pinned projects", async ({ page }) => {
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("response parse error", async ({ page }) => {
await page.route("*/**/v1/nodes/*", route => {
return route.fulfill({
json: [{ name: 1337 }],
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("response error", async ({ page }) => {
await page.route("*/**/v1/nodes/*", route => {
return route.fulfill({
status: 500,
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});