mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
Organise files more consistently
Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user