Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add patches read-only view
Sebastian Martinez committed 3 years ago
commit b5de1d70127c3906377e066247b6022deec34a04
parent c39ad9f520bdbb527ea79500f5ba3dc8bbfd7938
30 files changed +1472 -592
modified src/components/Authorship.svelte
@@ -5,16 +5,20 @@
  import { formatNodeId, formatTimestamp } from "@app/lib/utils";

  export let author: Author;
+
  export let caption: string | undefined = undefined;
+
  export let highlight: boolean = false;
  export let noAvatar: boolean = false;
-
  export let timestamp: number;
-
  export let caption: string;
+
  export let timestamp: number | undefined = undefined;
+

+
  const relativeTimestamp = (time: number | undefined) =>
+
    time ? new Date(time).toISOString() : undefined;
</script>

<style>
  .authorship {
-
    display: flex;
+
    display: inline-flex;
    align-items: center;
-
    color: var(--color-foreground);
+
    color: var(--color-foreground-5);
    padding: 0.125rem 0;
  }
  .id {
@@ -22,8 +26,10 @@
    text-overflow: ellipsis;
    white-space: nowrap;
  }
-
  .caption {
+
  .body {
+
    margin: 0 0.4rem;
    color: var(--color-foreground-5);
+
    white-space: nowrap;
  }
  .highlight {
    color: var(--color-foreground-6);
@@ -34,16 +40,26 @@
  }
</style>

-
<span class="authorship txt-tiny">
+
<span class="authorship txt-tiny" title={relativeTimestamp(timestamp)}>
  {#if !noAvatar}
    <Avatar inline source={author.id} title={author.id} />
  {/if}
-
  <span class="id highlight layout-desktop">{formatNodeId(author.id)}</span>
-
  <span class="id highlight layout-mobile">
+
  <span class:highlight class="id highlight layout-desktop">
+
    {formatNodeId(author.id)}
+
  </span>
+
  <span class:highlight class="id layout-mobile">
    {formatNodeId(author.id).replace("did:key:", "")}
  </span>
-
  <span class="caption">&nbsp;{caption}&nbsp;</span>
-
  <span class="date">
-
    {formatTimestamp(timestamp)}
+
  <span class="body">
+
    {#if !caption}
+
      <slot />
+
    {:else}
+
      {caption}
+
    {/if}
  </span>
+
  {#if timestamp}
+
    <span class:date={highlight}>
+
      {formatTimestamp(timestamp)}
+
    </span>
+
  {/if}
</span>
modified src/components/Badge.svelte
@@ -5,7 +5,9 @@
    | "negative"
    | "positive"
    | "primary"
+
    | "secondary"
    | "tertiary";
+
  export let style: string | undefined = undefined;
</script>

<style>
@@ -26,6 +28,10 @@
    color: var(--color-positive-6);
    background-color: var(--color-positive-3);
  }
+
  .secondary {
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-3);
+
  }
  .negative {
    color: var(--color-negative);
    background-color: var(--color-negative-4);
@@ -49,11 +55,13 @@
  on:mouseenter
  on:mouseleave
  class="badge"
+
  {style}
  class:caution={variant === "caution"}
  class:foreground={variant === "foreground"}
  class:negative={variant === "negative"}
  class:positive={variant === "positive"}
  class:primary={variant === "primary"}
+
  class:secondary={variant === "secondary"}
  class:tertiary={variant === "tertiary"}>
  <slot />
</span>
modified src/components/Chip.svelte
@@ -17,6 +17,7 @@
  .section {
    display: flex;
    align-items: center;
+
    max-width: 13rem;
    padding: 0.2rem 0.5rem;
  }
  .text {
@@ -44,7 +45,9 @@
</style>

<div class="chip">
-
  <span class="section text" class:removeable><slot name="text" /></span>
+
  <span class="section text" class:removeable>
+
    <slot />
+
  </span>
  {#if removeable}
    <button class="section close" on:click={() => dispatch("remove", key)}>
modified src/components/Comment.svelte
@@ -55,7 +55,12 @@
<div class="comment" {id}>
  <div class="card">
    <div class="card-header">
-
      <Authorship {caption} {author} {timestamp} />
+
      <div class="layout-desktop">
+
        <Authorship {caption} {author} {timestamp} />
+
      </div>
+
      <div class="layout-mobile">
+
        <Authorship {author} {timestamp} />
+
      </div>
      <div class="actions">
        {#if showReplyIcon}
          <Button
added src/components/DiffStatBadge.svelte
@@ -0,0 +1,37 @@
+
<script lang="ts">
+
  import type { DiffStats } from "@app/lib/diff";
+

+
  export let stats: DiffStats;
+
</script>
+

+
<style>
+
  .badge {
+
    font-size: var(--font-size-tiny);
+
    line-height: 1.6;
+
    height: var(--button-tiny-height);
+
    display: flex;
+
    flex-direction: row;
+
    white-space: nowrap;
+
  }
+
  .positive {
+
    padding: 0.125rem 0.5rem;
+
    border-radius: var(--border-radius) 0 0 var(--border-radius);
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-3);
+
  }
+
  .negative {
+
    padding: 0.125rem 0.5rem;
+
    border-radius: 0 var(--border-radius) var(--border-radius) 0;
+
    color: var(--color-negative);
+
    background-color: var(--color-negative-4);
+
  }
+
</style>
+

+
<div class="badge">
+
  <span class="positive">
+
    + {stats.insertions}
+
  </span>
+
  <span class="negative">
+
    - {stats.deletions}
+
  </span>
+
</div>
modified src/components/Dropdown.svelte
@@ -1,25 +1,26 @@
+
<script lang="ts" context="module">
+
  export interface Item<T> {
+
    key: string;
+
    title: string;
+
    value: T;
+
    badge: string | null;
+
  }
+
</script>
+

<script lang="ts" strictEvents>
-
  import type { State } from "@app/lib/issue";
+
  import type { State } from "@app/lib/cobs";

  import { createEventDispatcher } from "svelte";
  import { twemoji } from "@app/lib/utils";
-

  import Badge from "@app/components/Badge.svelte";

-
  type T = $$Generic<State | string>;
+
  type T = $$Generic<State | string | number>;

-
  interface Item {
-
    key: string;
-
    title: string;
-
    value: T;
-
    badge: string | null;
-
  }
-

-
  export let items: Item[];
+
  export let items: Item<T>[];
  export let selected: string | null = null;

-
  const dispatch = createEventDispatcher<{ select: Item }>();
-
  const onSelect = (item: Item) => {
+
  const dispatch = createEventDispatcher<{ select: Item<T> }>();
+
  const onSelect = (item: Item<T>) => {
    dispatch("select", item);
  };
</script>
@@ -47,17 +48,11 @@
  .selected {
    background-color: var(--color-foreground-2);
  }
-
  @media (max-width: 720px) {
-
    .dropdown {
-
      left: 32px;
-
      z-index: 10;
-
    }
-
  }
</style>

<div class="dropdown">
  {#each items as item}
-
    {#if item.key && item.value}
+
    {#if item.key}
      <!-- svelte-ignore a11y-click-events-have-key-events -->
      <div
        class="dropdown-item"
deleted src/components/DropdownButton.svelte
@@ -1,66 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/components/Button.svelte";
-
  import Icon from "./Icon.svelte";
-
  import { createEventDispatcher } from "svelte";
-

-
  export let title: string | undefined = undefined;
-
  export let variant:
-
    | "foreground"
-
    | "negative"
-
    | "primary"
-
    | "secondary"
-
    | "text";
-
  export let size: "tiny" | "small" | "regular" = "regular";
-
  export let autofocus: boolean = false;
-
  export let disabled: boolean = false;
-
  export let waiting: boolean = false;
-

-
  const dispatch = createEventDispatcher<{ toggle: never }>();
-

-
  const attachableStyle = `border-top-right-radius: 0;
-
    border-bottom-right-radius: 0;
-
    border-right: 0;`;
-
</script>
-

-
<style>
-
  .main {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: center;
-
    align-items: center;
-
  }
-
  .toggle {
-
    cursor: pointer;
-
    border: 1px solid var(--color-foreground);
-
    border-radius: var(--border-radius-round);
-
    border-top-left-radius: 0;
-
    height: var(--button-small-height);
-
    background: transparent;
-
    color: var(--color-foreground);
-
    border-bottom-left-radius: 0;
-
    line-height: 1.6rem;
-
    font-size: var(--font-size-regular);
-
    padding: 0 0.2rem;
-
  }
-
  .toggle:not([disabled]):hover {
-
    background-color: var(--color-foreground);
-
    color: var(--color-background);
-
  }
-
</style>
-

-
<div class="main">
-
  <Button
-
    {title}
-
    {variant}
-
    {autofocus}
-
    {disabled}
-
    {waiting}
-
    {size}
-
    on:click
-
    style={attachableStyle}>
-
    <slot />
-
  </Button>
-
  <button class="toggle" on:click={() => dispatch("toggle")}>
-
    <Icon name="chevron-down" />
-
  </button>
-
</div>
modified src/components/Markdown.svelte
@@ -14,13 +14,13 @@
  } from "@app/lib/markdown";

  export let content: string;
-
  export let doc = matter(content);
  export let hash: string | null = null;
  export let path: string = "/";
  export let breaks = false;
  export let rawPath: string;

-
  const frontMatter = Object.entries(doc.data).filter(
+
  $: doc = matter(content);
+
  $: frontMatter = Object.entries(doc.data).filter(
    ([, val]) => typeof val === "string" || typeof val === "number",
  );
  marked.use({ extensions, renderer, breaks });
modified src/components/TabBar.svelte
@@ -1,7 +1,7 @@
<script lang="ts" context="module">
  export interface Tab<T> {
    title?: string;
-
    count?: number;
+
    disabled: boolean;
    value: T;
  }
</script>
@@ -18,7 +18,7 @@
  const dispatch = createEventDispatcher<{ select: T }>();

  function onSelect(option: Tab<T>) {
-
    if (option.count !== 0) {
+
    if (!option.disabled) {
      dispatch("select", option.value);
    }
  }
@@ -60,11 +60,8 @@
    <button
      class="state-toggle"
      on:click={() => onSelect(option)}
-
      disabled={option.count === 0}
+
      disabled={option.disabled}
      class:active={active === option.value}>
-
      {#if option.count !== undefined}
-
        {option.count}
-
      {/if}
      {option.title ?? capitalize(`${option.value}`)}
    </button>
  {/each}
modified src/lib/cobs.ts
@@ -1,3 +1,8 @@
+
import type { IssueState } from "@app/lib/issue";
+
import type { PatchState } from "@app/lib/patch";
+

+
import { parseNodeId } from "@app/lib/utils";
+

export interface Comment {
  id: string;
  author: Author;
@@ -11,6 +16,11 @@ export interface Author {
  id: string;
}

+
export interface Thread {
+
  root: Comment;
+
  replies: Comment[];
+
}
+

export interface PeerIdentity {
  id: string;
}
@@ -28,3 +38,34 @@ export function formatObjectId(id: string): string {
export function stripDidPrefix(array: string[]): string[] {
  return array.map(id => id.replace("did:key:", ""));
}
+

+
export type State = IssueState | PatchState;
+

+
export function validateTag(
+
  value: string,
+
  items: string[],
+
): { success: false; error: string } | { success: true } {
+
  if (value.trim().length > 0) {
+
    if (items.includes(value)) {
+
      return { success: false, error: "This tag is already added" };
+
    } else {
+
      return { success: true };
+
    }
+
  }
+
  return { success: false, error: "This tag is not valid" };
+
}
+

+
export function validateAssignee(
+
  value: string,
+
  items: string[],
+
): { success: false; error: string } | { success: true } {
+
  const nodeId = parseNodeId(value);
+
  if (nodeId) {
+
    if (items.includes(`${nodeId.prefix}${nodeId.pubkey}`)) {
+
      return { success: false, error: "This assignee is already added" };
+
    } else {
+
      return { success: true };
+
    }
+
  }
+
  return { success: false, error: "This assignee is not valid" };
+
}
modified src/lib/issue.ts
@@ -1,4 +1,4 @@
-
import type { Author, Comment } from "@app/lib/cobs";
+
import type { Author, Comment, State } from "@app/lib/cobs";
import type { Host } from "@app/lib/api";

import { stripDidPrefix } from "@app/lib/cobs";
@@ -8,21 +8,16 @@ export interface IIssue {
  id: string;
  author: Author;
  title: string;
-
  state: State;
+
  state: IssueState;
  discussion: Comment[];
  tags: string[];
  assignees: string[];
  timestamp: number;
}

-
export type State =
-
  | {
-
      status: "open";
-
    }
-
  | {
-
      status: "closed";
-
      reason: "other" | "solved";
-
    };
+
export type IssueState =
+
  | { status: "open" }
+
  | { status: "closed"; reason: "other" | "solved" };

export function groupIssues(issues: Issue[]): {
  open: Issue[];
@@ -51,7 +46,7 @@ export class Issue {
  id: string;
  author: Author;
  title: string;
-
  state: State;
+
  state: IssueState;
  discussion: Comment[];
  tags: string[];
  assignees: string[];
added src/lib/patch.ts
@@ -0,0 +1,150 @@
+
import type { Author, Comment } from "@app/lib/cobs";
+
import type { CommitHeader } from "@app/lib/commit";
+
import type { Diff } from "@app/lib/diff";
+
import type { Host } from "@app/lib/api";
+

+
import { Request } from "@app/lib/api";
+

+
interface IPatch {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  description: string;
+
  state: PatchState;
+
  target: string;
+
  tags: string[];
+
  revisions: Revision[];
+
}
+

+
export interface Revision {
+
  id: string;
+
  description: string;
+
  base: string;
+
  oid: string;
+
  refs: string[];
+
  discussions: Comment[];
+
  reviews: [string, Review][];
+
  merges: Merge[];
+
  timestamp: number;
+
}
+

+
export interface Review {
+
  verdict?: "accept" | "reject";
+
  comment?: string;
+
  inline: CodeComment[];
+
  timestamp: number;
+
}
+

+
export interface CodeComment {
+
  location: CodeLocation;
+
  comment: string;
+
  timestamp: number;
+
}
+

+
interface CodeLocation {
+
  path: string;
+
  commit: string;
+
  lines: {
+
    start: number;
+
    end: number;
+
  };
+
}
+

+
export interface Merge {
+
  node: string;
+
  commit: string;
+
  timestamp: number;
+
}
+

+
export type PatchState =
+
  | { status: "draft" }
+
  | { status: "proposed" }
+
  | { status: "archived" };
+

+
export class Patch {
+
  id: string;
+
  author: Author;
+
  title: string;
+
  description: string;
+
  state: PatchState;
+
  target: string;
+
  tags: string[];
+
  revisions: Revision[];
+

+
  constructor(patch: IPatch) {
+
    this.id = patch.id;
+
    this.author = patch.author;
+
    this.title = patch.title;
+
    this.description = patch.description;
+
    this.state = patch.state;
+
    this.target = patch.target;
+
    this.tags = patch.tags;
+
    this.revisions = patch.revisions;
+
  }
+

+
  async editTags(
+
    project: string,
+
    add: string[],
+
    remove: string[],
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/patches/${this.id}`, host).patch(
+
      {
+
        type: "tag",
+
        add,
+
        remove,
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  static async getPatches(id: string, host: Host): Promise<Patch[]> {
+
    const response: IPatch[] = await new Request(
+
      `projects/${id}/patches`,
+
      host,
+
    ).get();
+
    return response.map(patch => new Patch(patch));
+
  }
+

+
  async replyComment(
+
    project: string,
+
    revision: string,
+
    body: string,
+
    replyTo: string,
+
    host: Host,
+
    session: string,
+
  ): Promise<void> {
+
    await new Request(`projects/${project}/patches/${this.id}`, host).patch(
+
      {
+
        type: "thread",
+
        revision,
+
        action: {
+
          type: "comment",
+
          body,
+
          replyTo,
+
        },
+
      },
+
      { Authorization: `Bearer ${session}` },
+
    );
+
  }
+

+
  async getPatchDiff(
+
    project: string,
+
    revision: Revision,
+
    host: Host,
+
  ): Promise<{ commits: CommitHeader[]; diff: Diff }> {
+
    return await new Request(
+
      `projects/${project}/diff/${revision.base}/${revision.oid}`,
+
      host,
+
    ).get();
+
  }
+

+
  static async getPatch(id: string, patch: string, host: Host): Promise<Patch> {
+
    const response: IPatch = await new Request(
+
      `projects/${id}/patches/${patch}`,
+
      host,
+
    ).get();
+
    return new Patch(response);
+
  }
+
}
modified src/lib/router.ts
@@ -268,6 +268,13 @@ export function routeToPath(route: Route) {
      return `${hostPrefix}/${route.params.id}${peer}/issues${suffix}`;
    } else if (route.params.view.resource === "issue") {
      return `${hostPrefix}/${route.params.id}${peer}/issues/${route.params.view.params.issue}`;
+
    } else if (route.params.view.resource === "patches") {
+
      return `${hostPrefix}/${route.params.id}${peer}/patches${suffix}`;
+
    } else if (route.params.view.resource === "patch") {
+
      if (route.params.view.params.revision) {
+
        return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}/${route.params.view.params.revision}${suffix}`;
+
      }
+
      return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}${suffix}`;
    } else {
      return `${hostPrefix}/${route.params.id}${peer}${content}`;
    }
@@ -361,6 +368,30 @@ function resolveProjectRoute(
        revision: undefined,
      };
    }
+
  } else if (content === "patches") {
+
    const patch = segments.shift();
+
    const revision = segments.shift();
+
    if (patch) {
+
      return {
+
        view: { resource: "patch", params: { patch, revision } },
+
        id,
+
        seed,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
        search: sanitizeQueryString(url.search),
+
      };
+
    } else {
+
      return {
+
        view: { resource: "patches" },
+
        id,
+
        seed,
+
        peer,
+
        search: sanitizeQueryString(url.search),
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
  }

  return null;
modified src/lib/router/definitions.ts
@@ -20,7 +20,14 @@ export interface ProjectsParams {
        params?: {
          view: { resource: "new" };
        };
-
      };
+
      }
+
    | {
+
        resource: "patches";
+
        params?: {
+
          view: { resource: "new" };
+
        };
+
      }
+
    | { resource: "patch"; params: { patch: string; revision?: string } };
  seed: string;
  hash?: string;
  line?: string;
added src/views/projects/Cob/CobHeader.svelte
@@ -0,0 +1,95 @@
+
<script lang="ts" strictEvents>
+
  import Button from "@app/components/Button.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import { formatObjectId } from "@app/lib/cobs";
+

+
  export let action: "create" | "edit" | "view" = "view";
+
  export let id: string | undefined = undefined;
+
  export let title: string = "";
+

+
  const dispatch = createEventDispatcher<{ editTitle: string }>();
+

+
  $: editable = action === "create" ? true : false;
+
</script>
+

+
<style>
+
  header {
+
    background: var(--color-foreground-1);
+
    border-radius: var(--border-radius);
+
    margin-bottom: 1rem;
+
    padding: 1rem;
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 1rem;
+
    justify-content: space-between;
+
    align-items: center;
+
    margin-bottom: 0.5rem;
+
    padding-right: 0.5rem;
+
  }
+
  .summary-title {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: baseline;
+
    gap: 0.5rem;
+
    width: 90%;
+
  }
+
  .id {
+
    font-size: var(--font-size-tiny);
+
    color: var(--color-foreground-5);
+
  }
+
  .title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .summary-state {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 1rem;
+
    border-radius: var(--border-radius);
+
  }
+
</style>
+

+
<header>
+
  <div class="summary">
+
    <div class="summary-title txt-medium">
+
      {#if editable}
+
        <TextInput transparent variant="form" bind:value={title} />
+
      {:else}
+
        {#if title}
+
          <span class="txt-medium title">{title}</span>
+
        {:else}
+
          <span class="txt-missing">No title</span>
+
        {/if}
+
        <slot name="revision" />
+
        {#if id}
+
          <div class="txt-monospace id layout-desktop">{id}</div>
+
          <div class="txt-monospace id layout-mobile">
+
            {formatObjectId(id)}
+
          </div>
+
        {/if}
+
      {/if}
+
    </div>
+
    {#if action === "edit"}
+
      <Button
+
        variant="text"
+
        size="small"
+
        on:click={() => {
+
          editable = !editable;
+
          dispatch("editTitle", title);
+
        }}>
+
        {#if editable}
+
          save
+
        {:else}
+
          edit
+
        {/if}
+
      </Button>
+
    {/if}
+
  </div>
+
  <div class="summary-state">
+
    <slot name="state" />
+
  </div>
+
</header>
added src/views/projects/Cob/CobSideInput.svelte
@@ -0,0 +1,112 @@
+
<script lang="ts" strictEvents>
+
  import Button from "@app/components/Button.svelte";
+
  import Chip from "@app/components/Chip.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+
  import { createEventDispatcher, onMount } from "svelte";
+

+
  const dispatch = createEventDispatcher<{ save: string[] }>();
+

+
  export let action: "create" | "edit" | "view" = "view";
+
  export let title: string;
+
  export let edit: boolean = false;
+
  export let items: string[] = [];
+
  export let validate: (item: string) => boolean;
+
  export let validateAdd: (
+
    item: string,
+
    items: string[],
+
  ) => { success: false; error: string } | { success: true };
+
  export let placeholder: string;
+

+
  function toggleEdit() {
+
    edit = !edit;
+
  }
+

+
  function handleAdd() {
+
    const result = validateAdd(value, newItems);
+
    if (result.success) {
+
      newItems = [...newItems, value];
+
      value = "";
+
    } else {
+
      caption = result.error;
+
    }
+
  }
+

+
  onMount(() => {
+
    newItems = items;
+
  });
+

+
  let newItems: string[] = [];
+
  let value = "";
+
  let caption: string | undefined = undefined;
+
</script>
+

+
<style>
+
  .metadata-section {
+
    margin-bottom: 4rem;
+
  }
+
  .metadata-section-header {
+
    display: flex;
+
    gap: 1rem;
+
    align-items: center;
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .metadata-section-body {
+
    display: flex;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    margin-bottom: 1.25rem;
+
  }
+
  .metadata-section-empty {
+
    color: var(--color-foreground-6);
+
  }
+
</style>
+

+
<div class="metadata-section">
+
  <div class="metadata-section-header">
+
    <span>{title}</span>
+
    {#if action === "edit"}
+
      {#if !edit}
+
        <Button size="tiny" variant="text" on:click={toggleEdit}>edit</Button>
+
      {:else}
+
        <Button
+
          size="tiny"
+
          variant="text"
+
          on:click={() => {
+
            dispatch("save", newItems);
+
            toggleEdit();
+
          }}>
+
          save
+
        </Button>
+
      {/if}
+
    {/if}
+
  </div>
+
  <div class="metadata-section-body">
+
    {#each newItems as item, key (item)}
+
      <Chip
+
        on:remove={({ detail: key }) => {
+
          newItems = newItems.filter((_, i) => i !== key);
+
        }}
+
        removeable={edit || action === "create"}
+
        {key}>
+
        <slot {item} />
+
      </Chip>
+
    {:else}
+
      <div class="metadata-section-empty">No {title.toLowerCase()}</div>
+
    {/each}
+
  </div>
+
  {#if edit || action === "create"}
+
    <div style:margin-bottom="1rem">
+
      <TextInput
+
        bind:value
+
        valid={validate(value)}
+
        {placeholder}
+
        variant="form"
+
        validationMessage={caption}
+
        on:submit={handleAdd}
+
        on:input={() => (caption = undefined)} />
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Cob/CobStateButton.svelte
@@ -0,0 +1,74 @@
+
<script lang="ts">
+
  import type { Item } from "@app/components/Dropdown.svelte";
+
  import type { State } from "@app/lib/cobs";
+

+
  import Button from "@app/components/Button.svelte";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { createEventDispatcher } from "svelte";
+
  import { isEqual } from "lodash";
+

+
  export let state: State;
+
  export let selectedItem: Item<State>;
+
  export let items: Item<State>[];
+

+
  const dispatch = createEventDispatcher<{ saveStatus: State }>();
+

+
  function switchCaption({ detail: item }: CustomEvent<Item<State>>) {
+
    selectedItem = item;
+
    closeFocused();
+
  }
+

+
  const attachableStyle = `border-top-right-radius: 0;
+
    border-bottom-right-radius: 0;
+
    border-right: 0;`;
+
</script>
+

+
<style>
+
  .main {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: center;
+
  }
+
  .toggle {
+
    cursor: pointer;
+
    border: 1px solid var(--color-foreground);
+
    border-radius: var(--border-radius-round);
+
    border-top-left-radius: 0;
+
    height: var(--button-small-height);
+
    background: transparent;
+
    color: var(--color-foreground);
+
    border-bottom-left-radius: 0;
+
    line-height: 1.6rem;
+
    font-size: var(--font-size-regular);
+
    padding: 0 0.2rem;
+
  }
+
  .toggle:hover {
+
    background-color: var(--color-foreground);
+
    color: var(--color-background);
+
  }
+
</style>
+

+
<div class="main">
+
  <Button
+
    variant="foreground"
+
    size="small"
+
    on:click={() => dispatch("saveStatus", selectedItem.value)}
+
    style={attachableStyle}>
+
    {selectedItem.key}
+
  </Button>
+
  <Floating>
+
    <svelte:fragment slot="toggle">
+
      <button class="toggle">
+
        <Icon name="chevron-down" />
+
      </button>
+
    </svelte:fragment>
+
    <svelte:fragment slot="modal">
+
      <Dropdown
+
        on:select={switchCaption}
+
        items={items.filter(i => !isEqual(i.value, state))} />
+
    </svelte:fragment>
+
  </Floating>
+
</div>
modified src/views/projects/Header.svelte
@@ -22,7 +22,7 @@

  // Switches between project views.
  const toggleContent = (
-
    input: "issues" | "history",
+
    input: "issues" | "patches" | "history",
    keepSourceInPath: boolean,
  ) => {
    router.updateProjectRoute({
@@ -130,6 +130,14 @@
    <span class="txt-bold">{project.issues.open ?? 0}</span>
    issue(s)
  </HeaderToggleLabel>
+
  <HeaderToggleLabel
+
    ariaLabel="Patch count"
+
    active={activeRoute.params.view.resource === "patches"}
+
    clickable
+
    on:click={() => toggleContent("patches", false)}>
+
    <span class="txt-bold">{project.patches.proposed ?? 0}</span>
+
    patch(es)
+
  </HeaderToggleLabel>
  <HeaderToggleLabel ariaLabel="Contributor count">
    <span class="txt-bold">{tree.stats.contributors}</span>
    contributor(s)
modified src/views/projects/Issue.svelte
@@ -1,17 +1,24 @@
<script lang="ts" strictEvents>
  import type { Project } from "@app/lib/project";
-
  import type { State } from "@app/lib/issue";
+
  import type { IssueState } from "@app/lib/issue";
+
  import type { State } from "@app/lib/cobs";
+
  import type { Item } from "@app/components/Dropdown.svelte";

+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Avatar from "@app/components/Comment/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
-
  import IssueHeader from "@app/views/projects/Issue/IssueHeader.svelte";
-
  import IssueSidebar from "@app/views/projects/Issue/IssueSidebar.svelte";
-
  import IssueStateButton from "@app/views/projects/Issue/IssueStateButton.svelte";
+
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
+
  import CobSideInput from "./Cob/CobSideInput.svelte";
+
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
  import Textarea from "@app/components/Textarea.svelte";
  import Thread from "@app/components/Thread.svelte";
  import { createAddRemoveArrays, Issue } from "@app/lib/issue";
  import { createEventDispatcher } from "svelte";
  import { isLocal } from "@app/lib/utils";
+
  import { parseNodeId, formatNodeId } from "@app/lib/utils";
  import { sessionStore } from "@app/lib/session";
+
  import { validateAssignee, validateTag } from "@app/lib/cobs";

  export let issue: Issue;
  export let project: Project;
@@ -19,6 +26,25 @@
  const dispatch = createEventDispatcher<{ update: never }>();
  const rawPath = project.getRawPath();

+
  const action: "create" | "edit" | "view" =
+
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";
+
  const items: Item<IssueState>[] = [
+
    { title: "Reopen issue", state: { status: "open" } as const },
+
    {
+
      title: "Close issue as solved",
+
      state: { status: "closed", reason: "solved" } as const,
+
    },
+
    {
+
      title: "Close issue as other",
+
      state: { status: "closed", reason: "other" } as const,
+
    },
+
  ].map(item => ({
+
    key: item.title,
+
    title: item.title,
+
    value: item.state,
+
    badge: null,
+
  }));
+

  async function createReply({
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
@@ -108,6 +134,7 @@
    }
  }

+
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
  $: threads = issue.discussion
    .filter(comment => !comment.replyTo)
    .map(thread => {
@@ -129,6 +156,12 @@
    padding: 0 2rem 0 8rem;
    margin-bottom: 4.5rem;
  }
+
  .metadata {
+
    border-radius: var(--border-radius);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
    margin-left: 1rem;
+
  }

  .actions {
    display: flex;
@@ -137,33 +170,49 @@
    margin: 0 0 2.5rem 0;
    gap: 1rem;
  }
+
  .tag {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }

-
  @media (max-width: 720px) {
+
  @media (max-width: 960px) {
    .issue {
      display: grid;
      grid-template-columns: minmax(0, 1fr);
      padding-left: 2rem;
    }
-
  }
-

-
  @media (max-width: 960px) {
-
    .issue {
-
      padding-left: 2rem;
+
    .metadata {
+
      display: none;
    }
  }
</style>

<div class="issue">
  <div>
-
    <IssueHeader
+
    <CobHeader
      action="edit"
      id={issue.id}
-
      author={issue.author}
-
      timestamp={issue.timestamp}
-
      state={issue.state}
      title={issue.title}
-
      on:editTitle={editTitle} />
-
    <div class="comments">
+
      on:editTitle={editTitle}>
+
      <svelte:fragment slot="state">
+
        {#if issue.state.status === "open"}
+
          <Badge variant="positive">
+
            {issue.state.status}
+
          </Badge>
+
        {:else}
+
          <Badge variant="negative">
+
            {issue.state.status} as
+
            {issue.state.reason}
+
          </Badge>
+
        {/if}
+
        <Authorship
+
          highlight
+
          timestamp={issue.timestamp}
+
          author={issue.author}
+
          caption="opened this issue" />
+
      </svelte:fragment>
+
    </CobHeader>
+
    <div>
      {#each threads as thread, index (thread.root.id)}
        <Thread
          {thread}
@@ -181,7 +230,11 @@
          bind:value={commentBody}
          placeholder="Leave your comment" />
        <div class="actions txt-small">
-
          <IssueStateButton {issue} on:saveStatus={saveStatus} />
+
          <CobStateButton
+
            {items}
+
            {selectedItem}
+
            state={issue.state}
+
            on:saveStatus={saveStatus} />
          <Button
            variant="secondary"
            size="small"
@@ -196,12 +249,31 @@
      {/if}
    </div>
  </div>
-
  <!-- We need to spread issue.tags and issue.assignees to clone the object property
-
         else we pass a reference to the issue object. -->
-
  <IssueSidebar
-
    on:saveAssignees={saveAssignees}
-
    on:saveTags={saveTags}
-
    action={$sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view"}
-
    tags={[...issue.tags]}
-
    assignees={[...issue.assignees]} />
+
  <div class="metadata">
+
    <CobSideInput
+
      {action}
+
      title="Assignees"
+
      placeholder="Add assignee"
+
      items={[...issue.assignees]}
+
      on:save={saveAssignees}
+
      validate={item => Boolean(parseNodeId(item))}
+
      validateAdd={(item, items) => validateAssignee(item, items)}>
+
      <svelte:fragment let:item>
+
        <Avatar inline source={item} title={item} />
+
        <span>{formatNodeId(item)}</span>
+
      </svelte:fragment>
+
    </CobSideInput>
+
    <CobSideInput
+
      {action}
+
      title="Tags"
+
      placeholder="Add tag"
+
      items={[...issue.tags]}
+
      on:save={saveTags}
+
      validate={item => item.trim().length > 0}
+
      validateAdd={(item, items) => validateTag(item, items)}>
+
      <svelte:fragment let:item>
+
        <div class="tag">{item}</div>
+
      </svelte:fragment>
+
    </CobSideInput>
+
  </div>
</div>
deleted src/views/projects/Issue/IssueHeader.svelte
@@ -1,115 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Author } from "@app/lib/cobs";
-
  import type { State } from "@app/lib/issue";
-

-
  import Authorship from "@app/components/Authorship.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import { createEventDispatcher } from "svelte";
-
  import { formatObjectId } from "@app/lib/cobs";
-

-
  export let action: "create" | "edit" | "view" = "view";
-
  export let author: Author;
-
  export let id: string | undefined = undefined;
-
  export let state: State;
-
  export let timestamp: number;
-
  export let title: string = "";
-

-
  const dispatch = createEventDispatcher<{ editTitle: string }>();
-

-
  $: editable = action === "create" ? true : false;
-
</script>
-

-
<style>
-
  header {
-
    background: var(--color-foreground-1);
-
    border-radius: var(--border-radius);
-
    margin-bottom: 1rem;
-
    padding: 1rem;
-
  }
-
  .summary {
-
    display: flex;
-
    flex-direction: row;
-
    gap: 1rem;
-
    justify-content: space-between;
-
    align-items: center;
-
    margin-bottom: 1rem;
-
  }
-
  .summary-title {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: baseline;
-
    gap: 1rem;
-
    width: 100%;
-
    overflow: hidden;
-
    white-space: nowrap;
-
    text-overflow: ellipsis;
-
  }
-
  .id {
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-foreground-5);
-
  }
-
  .summary-state {
-
    display: flex;
-
    flex-direction: row;
-
    gap: 1rem;
-
    border-radius: var(--border-radius);
-
  }
-

-
  @media (max-width: 960px) {
-
    .summary-state {
-
      margin-left: 0.5rem;
-
    }
-
  }
-
</style>
-

-
<header>
-
  <div class="summary">
-
    <div class="summary-title txt-medium">
-
      {#if editable}
-
        <TextInput transparent variant="form" bind:value={title} />
-
      {:else}
-
        {#if title}
-
          <span class="txt-medium">{title}</span>
-
        {:else}
-
          <span class="txt-missing">No title</span>
-
        {/if}
-
        {#if id}
-
          <div class="txt-monospace id layout-desktop">{id}</div>
-
          <div class="txt-monospace id layout-mobile">
-
            {formatObjectId(id)}
-
          </div>
-
        {/if}
-
      {/if}
-
    </div>
-
    {#if action === "edit"}
-
      <Button
-
        variant="text"
-
        size="small"
-
        on:click={() => {
-
          editable = !editable;
-
          dispatch("editTitle", title);
-
        }}>
-
        {#if editable}
-
          save
-
        {:else}
-
          edit
-
        {/if}
-
      </Button>
-
    {/if}
-
  </div>
-
  <div class="summary-state">
-
    {#if state.status === "open"}
-
      <Badge variant="positive">
-
        {state.status}
-
      </Badge>
-
    {:else}
-
      <Badge variant="negative">
-
        {state.status} as
-
        {state.reason}
-
      </Badge>
-
    {/if}
-
    <Authorship {timestamp} {author} caption="opened this issue" />
-
  </div>
-
</header>
deleted src/views/projects/Issue/IssueSidebar.svelte
@@ -1,208 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import Avatar from "@app/components/Comment/Avatar.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import Chip from "@app/components/Chip.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-
  import { createEventDispatcher, onMount } from "svelte";
-
  import { formatNodeId, parseNodeId } from "@app/lib/utils";
-

-
  export let action: "create" | "edit" | "view" = "view";
-
  export let assignees: string[] = [];
-
  export let tags: string[] = [];
-

-
  const dispatch = createEventDispatcher<{
-
    saveAssignees: string[];
-
    saveTags: string[];
-
  }>();
-

-
  let editAssignees: boolean = false;
-
  let editTags: boolean = false;
-

-
  let assignee: string = "";
-
  let newAssignees: string[] = [];
-
  let assigneeCaption: string | undefined = undefined;
-
  let tag: string = "";
-
  let newTags: string[] = [];
-
  let tagCaption: string | undefined = undefined;
-

-
  onMount(() => {
-
    newAssignees = assignees;
-
    newTags = tags;
-
  });
-

-
  function handleAddAssignee() {
-
    const nodeId = parseNodeId(assignee);
-
    if (nodeId) {
-
      if (newAssignees.includes(`${nodeId.prefix}${nodeId.pubkey}`)) {
-
        assigneeCaption = "This user is already assigned";
-
        return;
-
      }
-
      newAssignees = [...newAssignees, `${nodeId.prefix}${nodeId.pubkey}`];
-
      if (action === "create") {
-
        dispatch("saveAssignees", newAssignees);
-
      }
-
      assignee = "";
-
    } else {
-
      assigneeCaption = "This user is not valid";
-
    }
-
  }
-

-
  function handleAddTag() {
-
    if (newTags.includes(tag)) {
-
      tagCaption = "This tag already exists";
-
      return;
-
    }
-
    newTags = [...newTags, tag];
-
    if (action === "create") {
-
      dispatch("saveTags", newTags);
-
    }
-
    tag = "";
-
  }
-
</script>
-

-
<style>
-
  .assignee {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
  }
-
  .tag {
-
    max-width: 15rem;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .metadata {
-
    margin-left: 1rem;
-
    border-radius: var(--border-radius);
-
    font-size: var(--font-size-small);
-
    padding-left: 1rem;
-
  }
-
  .metadata-section {
-
    margin-bottom: 4rem;
-
  }
-
  .metadata-section-header {
-
    display: flex;
-
    gap: 1rem;
-
    align-items: center;
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .metadata-section-body {
-
    display: flex;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    margin-bottom: 1.25rem;
-
  }
-
  .metadata-section-empty {
-
    color: var(--color-foreground-6);
-
  }
-
</style>
-

-
<div class="metadata layout-desktop">
-
  <div class="metadata-section">
-
    <div class="metadata-section-header">
-
      <span>Assignees</span>
-
      {#if action === "edit"}
-
        {#if !editAssignees}
-
          <Button
-
            size="tiny"
-
            variant="text"
-
            on:click={() => (editAssignees = !editAssignees)}>
-
            edit
-
          </Button>
-
        {:else}
-
          <Button
-
            size="tiny"
-
            variant="text"
-
            on:click={() => {
-
              dispatch("saveAssignees", newAssignees);
-
              editAssignees = !editAssignees;
-
            }}>
-
            save
-
          </Button>
-
        {/if}
-
      {/if}
-
    </div>
-
    <div class="metadata-section-body">
-
      {#each newAssignees as assignee, key (assignee)}
-
        <Chip
-
          on:remove={({ detail: key }) =>
-
            (newAssignees = newAssignees.filter((_, i) => i !== key))}
-
          removeable={editAssignees || action === "create"}
-
          {key}>
-
          <div slot="text" class="assignee">
-
            <Avatar inline source={assignee} title={assignee} />
-
            <span>{formatNodeId(assignee)}</span>
-
          </div>
-
        </Chip>
-
      {:else}
-
        <div class="metadata-section-empty">No assignees</div>
-
      {/each}
-
    </div>
-
    {#if editAssignees || action === "create"}
-
      <div style:margin-bottom="1rem">
-
        <TextInput
-
          bind:value={assignee}
-
          variant="form"
-
          on:submit={handleAddAssignee}
-
          on:input={() => (assigneeCaption = undefined)}
-
          placeholder="Assign this issue"
-
          validationMessage={assigneeCaption}
-
          valid={Boolean(parseNodeId(assignee))} />
-
      </div>
-
    {/if}
-
  </div>
-
  <div class="metadata-section">
-
    <div class="metadata-section-header">
-
      <span>Tags</span>
-
      {#if action === "edit"}
-
        {#if !editTags}
-
          <Button
-
            size="tiny"
-
            variant="text"
-
            on:click={() => (editTags = !editTags)}>
-
            edit
-
          </Button>
-
        {:else}
-
          <Button
-
            size="tiny"
-
            variant="text"
-
            on:click={() => {
-
              dispatch("saveTags", newTags);
-
              editTags = !editTags;
-
            }}>
-
            save
-
          </Button>
-
        {/if}
-
      {/if}
-
    </div>
-
    <div class="metadata-section-body">
-
      {#each newTags as tag, key (tag)}
-
        <Chip
-
          on:remove={({ detail: key }) =>
-
            (newTags = newTags.filter((_, i) => i !== key))}
-
          removeable={editTags || action === "create"}
-
          {key}>
-
          <span class="tag" slot="text">{tag}</span>
-
        </Chip>
-
      {:else}
-
        <div class="metadata-section-empty">No tags</div>
-
      {/each}
-
    </div>
-
    {#if editTags || action === "create"}
-
      <div style:margin-bottom="1rem">
-
        <TextInput
-
          bind:value={tag}
-
          variant="form"
-
          on:submit={handleAddTag}
-
          on:input={() => (tagCaption = undefined)}
-
          placeholder="Tag this issue"
-
          validationMessage={tagCaption}
-
          valid={tag !== ""} />
-
      </div>
-
    {/if}
-
  </div>
-
</div>
deleted src/views/projects/Issue/IssueStateButton.svelte
@@ -1,76 +0,0 @@
-
<script lang="ts">
-
  import type { Issue, State } from "@app/lib/issue";
-

-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownButton from "@app/components/DropdownButton.svelte";
-
  import { createEventDispatcher } from "svelte";
-
  import { isEqual } from "lodash";
-

-
  export let issue: Issue;
-

-
  const dispatch = createEventDispatcher<{ saveStatus: State }>();
-
  let showStateDropdown = false;
-

-
  interface Item {
-
    key: string;
-
    title: string;
-
    value: State;
-
    badge: null;
-
  }
-

-
  const items: Item[] = [
-
    {
-
      key: "Reopen issue",
-
      title: "Reopen issue",
-
      value: { status: "open" },
-
      badge: null,
-
    },
-
    {
-
      key: "Close issue as solved",
-
      title: "Close issue as solved",
-
      value: { status: "closed", reason: "solved" },
-
      badge: null,
-
    },
-
    {
-
      key: "Close issue as other",
-
      title: "Close issue as other",
-
      value: { status: "closed", reason: "other" },
-
      badge: null,
-
    },
-
  ];
-

-
  function switchCaption({ detail: item }: CustomEvent<any>) {
-
    showStateDropdown = false;
-
    selectedItem = item;
-
  }
-

-
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
-
</script>
-

-
<style>
-
  .main {
-
    position: relative;
-
  }
-
  .dropdown {
-
    position: absolute;
-
    right: 11rem;
-
    top: 2.5rem;
-
  }
-
</style>
-

-
<div class="main">
-
  <DropdownButton
-
    variant="foreground"
-
    size="small"
-
    on:toggle={() => (showStateDropdown = !showStateDropdown)}
-
    on:click={() => dispatch("saveStatus", selectedItem.value)}>
-
    {selectedItem.key}
-
  </DropdownButton>
-
  {#if showStateDropdown}
-
    <div class="dropdown">
-
      <Dropdown
-
        on:select={switchCaption}
-
        items={items.filter(i => !isEqual(i.value, issue.state))} />
-
    </div>
-
  {/if}
-
</div>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -4,6 +4,8 @@
  import { formatObjectId } from "@app/lib/cobs";
  import Authorship from "@app/components/Authorship.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import { formatTimestamp } from "@app/lib/utils";
+
  import Badge from "@app/components/Badge.svelte";

  export let issue: Issue;

@@ -21,17 +23,21 @@
    background-color: var(--color-foreground-2);
    cursor: pointer;
  }
-
  .issue-id {
+
  .subtitle {
    color: var(--color-foreground-5);
    font-size: var(--font-size-tiny);
    font-family: var(--font-family-monospace);
-
    margin-left: 0.5rem;
+
    margin-right: 0.4rem;
+
  }
+
  .id {
+
    margin-right: 0.5rem;
  }
  .summary {
    display: flex;
    flex-direction: row;
    align-items: center;
-
    padding-right: 2rem;
+
    gap: 0.5rem;
+
    padding-right: 1rem;
  }
  .issue-title {
    overflow: hidden;
@@ -46,6 +52,15 @@
    gap: 0.5rem;
    color: var(--color-foreground-5);
  }
+
  .tags {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
  }
+
  .tag {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }

  .column-right {
    align-self: center;
@@ -56,6 +71,9 @@
    justify-self: center;
    align-self: center;
  }
+
  .highlight {
+
    color: var(--color-foreground-6);
+
  }
  .state-icon {
    width: 0.5rem;
    height: 0.5rem;
@@ -67,6 +85,12 @@
  .closed {
    background-color: var(--color-negative);
  }
+

+
  @media (max-width: 960px) {
+
    .tags {
+
      display: none;
+
    }
+
  }
</style>

<div class="issue-teaser">
@@ -79,12 +103,28 @@
  <div class="column-left">
    <div class="summary">
      <span class="issue-title">{issue.title}</span>
-
      <span class="issue-id">{formatObjectId(issue.id)}</span>
+
      <span class="tags">
+
        {#each issue.tags.slice(0, 4) as tag}
+
          <Badge style="max-width:7rem" variant="secondary">
+
            <span class="tag">{tag}</span>
+
          </Badge>
+
        {/each}
+
        {#if issue.tags.length > 4}
+
          <Badge variant="foreground">
+
            <span class="tag">+{issue.tags.length - 4} more tags</span>
+
          </Badge>
+
        {/if}
+
      </span>
+
    </div>
+
    <div class="summary subtitle">
+
      <span class="id">
+
        <span class="highlight">{formatObjectId(issue.id)}</span>
+
        opened
+
        <span class="highlight">{formatTimestamp(issue.timestamp)}</span>
+
        by
+
      </span>
+
      <Authorship highlight noAvatar author={issue.author} />
    </div>
-
    <Authorship
-
      caption="opened"
-
      author={issue.author}
-
      timestamp={issue.timestamp} />
  </div>
  {#if commentCount > 0}
    <div class="column-right">
modified src/views/projects/Issue/New.svelte
@@ -4,18 +4,25 @@

  import * as modal from "@app/lib/modal";
  import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Avatar from "@app/components/Comment/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
+
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
+
  import CobSideInput from "@app/views/projects/Cob/CobSideInput.svelte";
  import Comment from "@app/components/Comment.svelte";
-
  import IssueSidebar from "@app/views/projects/Issue/IssueSidebar.svelte";
-
  import IssueHeader from "@app/views/projects/Issue/IssueHeader.svelte";
  import { Issue } from "@app/lib/issue";
  import { createEventDispatcher } from "svelte";
-
  import { stripDidPrefix } from "@app/lib/cobs";
+
  import { formatNodeId, isLocal, parseNodeId } from "@app/lib/utils";
+
  import { sessionStore } from "@app/lib/session";
+
  import { stripDidPrefix, validateTag } from "@app/lib/cobs";

  export let session: Session;
  export let project: Project;

  const dispatch = createEventDispatcher<{ create: string }>();
+
  const action: "edit" | "view" =
+
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";

  let preview: boolean = false;

@@ -66,6 +73,12 @@
    gap: 1rem;
    margin-top: 1rem;
  }
+
  .metadata {
+
    border-radius: var(--border-radius);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
    margin-left: 1rem;
+
  }
  .editor {
    flex: 2;
    padding-right: 1rem;
@@ -85,12 +98,15 @@
<main>
  <div class="form">
    <div class="editor">
-
      <IssueHeader
-
        author={{ id: session.publicKey }}
-
        state={{ status: "open" }}
-
        timestamp={Date.now()}
-
        action={preview ? "view" : "create"}
-
        bind:title={issueTitle} />
+
      <CobHeader action={preview ? "view" : "create"} bind:title={issueTitle}>
+
        <svelte:fragment slot="state">
+
          <Badge variant="positive">open</Badge>
+
          <Authorship
+
            timestamp={Date.now()}
+
            author={{ id: session.publicKey }}
+
            caption="opened this issue" />
+
        </svelte:fragment>
+
      </CobHeader>
      <div class="comments">
        <Comment
          bind:body={issueText}
@@ -120,9 +136,29 @@
        </Button>
      </div>
    </div>
-
    <IssueSidebar
-
      on:saveAssignees={({ detail }) => (assignees = detail)}
-
      on:saveTags={({ detail }) => (tags = detail)}
-
      action="create" />
+
    <div class="metadata">
+
      <CobSideInput
+
        {action}
+
        title="Assignees"
+
        placeholder="Add assignee"
+
        on:save={({ detail: assignees }) => (assignees = assignees)}
+
        validate={item => Boolean(parseNodeId(item))}
+
        validateAdd={(item, items) => validateTag(item, items)}>
+
        <svelte:fragment let:item>
+
          <Avatar inline source={item} title={item} />
+
          <span>{formatNodeId(item)}</span>
+
        </svelte:fragment>
+
      </CobSideInput>
+
      <CobSideInput
+
        title="Tags"
+
        placeholder="Add tag"
+
        on:save={({ detail: tags }) => (tags = tags)}
+
        validate={item => item.trim().length > 0}
+
        validateAdd={(item, items) => validateTag(item, items)}>
+
        <svelte:fragment let:item>
+
          <div class="tag">{item}</div>
+
        </svelte:fragment>
+
      </CobSideInput>
+
    </div>
  </div>
</main>
modified src/views/projects/Issues.svelte
@@ -1,5 +1,7 @@
<script lang="ts" context="module">
-
  export type State = "open" | "closed";
+
  import type { IssueState } from "@app/lib/issue";
+

+
  export type IssueStatus = IssueState["status"];
</script>

<script lang="ts">
@@ -19,27 +21,25 @@
  import TabBar from "@app/components/TabBar.svelte";

  export let issues: Issue[];
-
  export let state: State;
+
  export let status: IssueStatus;
  export let project: Project;

-
  let options: Tab<State>[];
-
  const { open, closed } = groupIssues(issues);
+
  let options: Tab<IssueStatus>[];

-
  $: filteredIssues = state === "open" ? open : closed;
+
  const stateOptions: IssueStatus[] = ["open", "closed"];
+
  $: options = stateOptions.map<{
+
    value: IssueStatus;
+
    title: string;
+
    disabled: boolean;
+
  }>((s: IssueStatus) => ({
+
    value: s,
+
    title: `${project.issues[s]} ${s}`,
+
    disabled: project.issues[s] === 0,
+
  }));
+
  $: filteredIssues = groupIssues(issues)[status];
  $: sortedIssues = filteredIssues.sort(
    ({ timestamp: t1 }, { timestamp: t2 }) => t2 - t1,
  );
-

-
  $: options = [
-
    {
-
      value: "open",
-
      count: project.issues.open,
-
    },
-
    {
-
      value: "closed",
-
      count: project.issues.closed,
-
    },
-
  ];
</script>

<style>
@@ -77,7 +77,7 @@
          router.updateProjectRoute({
            search: `state=${e.detail}`,
          })}
-
        active={state} />
+
        active={status} />
    </div>
    <HeaderToggleLabel
      disabled={!$sessionStore || !utils.isLocal(project.seed.host)}
@@ -114,7 +114,7 @@
    </div>
  {:else}
    <Placeholder emoji="🍂">
-
      <div slot="title">{capitalize(state)} issues</div>
+
      <div slot="title">{capitalize(status)} issues</div>
      <div slot="body">No issues matched the current filter</div>
    </Placeholder>
  {/if}
added src/views/projects/Patch.svelte
@@ -0,0 +1,376 @@
+
<script lang="ts" context="module">
+
  import type * as cobs from "@app/lib/cobs";
+
  import type { Merge, Review } from "@app/lib/patch";
+

+
  export interface TimelineReview {
+
    inner: [string, Review];
+
    type: "review";
+
    timestamp: number;
+
  }
+

+
  export interface TimelineMerge {
+
    inner: Merge;
+
    type: "merge";
+
    timestamp: number;
+
  }
+

+
  export interface TimelineThread {
+
    inner: cobs.Thread;
+
    type: "thread";
+
    timestamp: number;
+
  }
+
</script>
+

+
<script lang="ts">
+
  import type { Project } from "@app/lib/project";
+

+
  import * as router from "@app/lib/router";
+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Changeset from "./SourceBrowser/Changeset.svelte";
+
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
+
  import CobSideInput from "./Cob/CobSideInput.svelte";
+
  import Comment from "@app/components/Comment.svelte";
+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import HeaderToggleLabel from "./HeaderToggleLabel.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import TabBar from "@app/components/TabBar.svelte";
+
  import Thread from "@app/components/Thread.svelte";
+
  import { Patch } from "@app/lib/patch";
+
  import { capitalize, formatCommit, isLocal } from "@app/lib/utils";
+
  import { createAddRemoveArrays } from "@app/lib/issue";
+
  import { formatObjectId, validateTag } from "@app/lib/cobs";
+
  import { sessionStore } from "@app/lib/session";
+

+
  export let patch: Patch;
+
  export let revision: string | undefined = undefined;
+
  export let currentTab: "activity" | "commits";
+
  export let project: Project;
+

+
  const browseCommit = (event: { detail: string }) => {
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      search: undefined,
+
      revision: event.detail,
+
    });
+
  };
+

+
  async function createReply({
+
    detail: reply,
+
  }: CustomEvent<{ id: string; body: string }>) {
+
    if ($sessionStore && reply.body.trim().length > 0) {
+
      await patch.replyComment(
+
        project.id,
+
        currentRevision.id,
+
        reply.body,
+
        reply.id,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      patch = await Patch.getPatch(project.id, patch.id, project.seed.addr);
+
    }
+
  }
+

+
  async function saveTags({ detail: tags }: CustomEvent<string[]>) {
+
    if ($sessionStore) {
+
      const { add, remove } = createAddRemoveArrays(patch.tags, tags);
+
      if (add.length === 0 && remove.length === 0) {
+
        return;
+
      }
+
      await patch.editTags(
+
        project.id,
+
        add,
+
        remove,
+
        project.seed.addr,
+
        $sessionStore.id,
+
      );
+
      patch = await Patch.getPatch(project.id, patch.id, project.seed.addr);
+
    }
+
  }
+

+
  function formatVerdict(verdict?: string) {
+
    switch (verdict) {
+
      case "accept":
+
        return "accepted this revision";
+
      case "reject":
+
        return "rejected this revision";
+
      default:
+
        return "left a comment";
+
    }
+
  }
+

+
  const action: "create" | "edit" | "view" =
+
    $sessionStore && isLocal(project.seed.addr.host) ? "edit" : "view";
+

+
  // Reactive due to eventual changes in patch.revisions
+
  $: enumeratedRevisions = patch.revisions.map((r, i) => [r, i] as const);
+
  $: currentRevisionTuple =
+
    enumeratedRevisions.find(([rev]) => rev.id === revision) ||
+
    enumeratedRevisions[enumeratedRevisions.length - 1];
+
  $: [currentRevision, currentRevisionIndex] = currentRevisionTuple;
+
  $: options = ["activity", "commits", "files"].map(o => ({
+
    value: o,
+
    title: capitalize(o),
+
    disabled: false,
+
  }));
+
  $: reviews = currentRevision.reviews.map<TimelineReview>(
+
    ([author, review]) => ({
+
      timestamp: review.timestamp,
+
      type: "review",
+
      inner: [author, review],
+
    }),
+
  );
+
  $: merges = currentRevision.merges.map<TimelineMerge>(inner => ({
+
    timestamp: inner.timestamp,
+
    type: "merge",
+
    inner,
+
  }));
+
  $: threads = currentRevision.discussions
+
    .filter(comment => !comment.replyTo)
+
    .map<TimelineThread>(
+
      thread => ({
+
        timestamp: thread.timestamp,
+
        type: "thread",
+
        inner: {
+
          root: thread,
+
          replies: currentRevision.discussions
+
            .filter(comment => comment.replyTo === thread.id)
+
            .sort((a, b) => a.timestamp - b.timestamp),
+
        },
+
      }),
+
      [],
+
    );
+
  $: timeline = [...reviews, ...merges, ...threads].sort(
+
    (a, b) => a.timestamp - b.timestamp,
+
  );
+
  $: diffPromise = patch.getPatchDiff(
+
    project.id,
+
    currentRevision,
+
    project.seed.addr,
+
  );
+
</script>
+

+
<style>
+
  .patch {
+
    display: grid;
+
    grid-template-columns: minmax(0, 3fr) 1fr;
+
    padding: 0 2rem 0 8rem;
+
    margin-bottom: 4.5rem;
+
  }
+
  .metadata {
+
    border-radius: var(--border-radius);
+
    font-size: var(--font-size-small);
+
    padding-left: 1rem;
+
    margin-left: 1rem;
+
  }
+
  .action {
+
    margin: 1rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .highlight {
+
    color: var(--color-foreground-6);
+
  }
+
  .tag {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+

+
  @media (max-width: 1092px) {
+
    .patch {
+
      display: grid;
+
      grid-template-columns: minmax(0, 1fr);
+
    }
+
    .metadata {
+
      display: none;
+
    }
+
  }
+
  @media (max-width: 960px) {
+
    .patch {
+
      padding-left: 2rem;
+
    }
+
  }
+
</style>
+

+
<div class="patch">
+
  <div>
+
    <CobHeader id={patch.id} title={patch.title}>
+
      <span slot="revision" class="revision txt-monospace txt-tiny">
+
        <Floating>
+
          <svelte:fragment slot="toggle">
+
            <HeaderToggleLabel
+
              clickable={patch.revisions.length > 1}
+
              disabled={patch.revisions.length === 1}
+
              title="Toggle revision">
+
              Revision {currentRevisionIndex}
+
            </HeaderToggleLabel>
+
          </svelte:fragment>
+
          <svelte:fragment slot="modal">
+
            <Dropdown
+
              items={enumeratedRevisions.map(([r, i]) => {
+
                return {
+
                  key: `Revision ${i} (${formatObjectId(r.id)})`,
+
                  title: `Revision ${i} (${formatObjectId(r.id)})`,
+
                  value: r.id,
+
                  badge: null,
+
                };
+
              })}
+
              selected={currentRevision.toString()}
+
              on:select={({ detail: item }) => {
+
                router.updateProjectRoute({
+
                  view: {
+
                    resource: "patch",
+
                    params: { patch: patch.id, revision: item.value },
+
                  },
+
                });
+
              }} />
+
          </svelte:fragment>
+
        </Floating>
+
      </span>
+
      <svelte:fragment slot="state">
+
        {#if patch.state.status === "draft"}
+
          <Badge variant="foreground">
+
            {patch.state.status}
+
          </Badge>
+
        {:else if patch.state.status === "proposed"}
+
          <Badge variant="positive">
+
            {patch.state.status}
+
          </Badge>
+
        {:else}
+
          <Badge variant="positive">
+
            {patch.state.status}
+
          </Badge>
+
        {/if}
+
        <div class="layout-desktop">
+
          <Authorship
+
            highlight
+
            timestamp={patch.revisions[0].timestamp}
+
            author={patch.author}
+
            caption="opened this patch" />
+
        </div>
+
        <div class="layout-mobile">
+
          <Authorship highlight author={patch.author} />
+
        </div>
+
      </svelte:fragment>
+
    </CobHeader>
+
    <TabBar
+
      {options}
+
      active={currentTab}
+
      on:select={({ detail: tab }) =>
+
        router.updateProjectRoute({
+
          search: `tab=${tab}`,
+
        })} />
+
    {#if currentTab === "activity"}
+
      <div style:margin-top="1rem">
+
        <div class="txt-tiny">
+
          <Comment
+
            caption="created this revision"
+
            author={patch.author}
+
            timestamp={currentRevision.timestamp}
+
            rawPath={project.getRawPath()}
+
            body={currentRevisionIndex === 0
+
              ? patch.description
+
              : currentRevision.description} />
+
        </div>
+
        {#each timeline as element}
+
          {#if element.type === "thread"}
+
            <!-- TODO: Implement reply creation and comment editing -->
+
            <Thread
+
              rawPath={project.getRawPath()}
+
              isDescription={false}
+
              thread={element.inner}
+
              on:reply={createReply}
+
              on:select={({ detail: index }) => (currentRevision = index)} />
+
          {:else if element.type === "merge"}
+
            <div class="action layout-desktop txt-tiny">
+
              <Authorship
+
                author={{ id: element.inner.node }}
+
                timestamp={element.timestamp}>
+
                merged
+
                <span class="highlight">
+
                  {formatCommit(element.inner.commit)}
+
                </span>
+
              </Authorship>
+
            </div>
+
            <div class="action layout-mobile txt-tiny">
+
              <Authorship author={{ id: element.inner.node }}>
+
                merged
+
                <span class="highlight">
+
                  {formatCommit(element.inner.commit)}
+
                </span>
+
              </Authorship>
+
            </div>
+
          {:else if element.type === "review"}
+
            <!-- TODO: Implement inline code comments -->
+
            {@const [author, review] = element.inner}
+
            <div class="action layout-desktop txt-tiny">
+
              <Authorship author={{ id: author }} timestamp={element.timestamp}>
+
                {formatVerdict(review.verdict)}
+
              </Authorship>
+
            </div>
+
            <div class="action layout-mobile txt-tiny">
+
              <Authorship author={{ id: author }}>
+
                {formatVerdict(review.verdict)}
+
              </Authorship>
+
            </div>
+
            {#if review.comment}
+
              <Comment
+
                caption="left a comment"
+
                author={{ id: author }}
+
                timestamp={review.timestamp}
+
                rawPath={project.getRawPath()}
+
                body={review.comment} />
+
            {/if}
+
          {/if}
+
        {/each}
+
      </div>
+
    {:else if currentTab === "commits"}
+
      {#await diffPromise then diff}
+
        <div style:margin-top="1rem">
+
          {#each diff.commits as commit}
+
            <CommitTeaser
+
              commit={{ commit: commit }}
+
              on:click={() => {
+
                router.updateProjectRoute({
+
                  view: { resource: "commits" },
+
                  revision: commit.id,
+
                  search: undefined,
+
                });
+
              }}
+
              on:browseCommit={browseCommit} />
+
          {/each}
+
        </div>
+
      {/await}
+
    {:else if currentTab === "files"}
+
      {#await diffPromise then diff}
+
        <div style:margin-top="1rem">
+
          <Changeset
+
            diff={diff.diff}
+
            stats={diff.diff.stats}
+
            on:browse={({ detail: path }) => {
+
              router.updateProjectRoute({
+
                view: { resource: "tree" },
+
                search: undefined,
+
                revision: currentRevision.oid,
+
                path,
+
              });
+
            }} />
+
        </div>
+
      {/await}
+
    {/if}
+
  </div>
+
  <div class="metadata">
+
    <CobSideInput
+
      {action}
+
      title="Tags"
+
      placeholder="Add tag"
+
      items={patch.tags}
+
      on:save={saveTags}
+
      validate={item => item.trim().length > 0}
+
      validateAdd={(item, items) => validateTag(item, items)}>
+
      <svelte:fragment let:item>
+
        <div class="tag">{item}</div>
+
      </svelte:fragment>
+
    </CobSideInput>
+
  </div>
+
</div>
added src/views/projects/Patch/PatchTeaser.svelte
@@ -0,0 +1,154 @@
+
<script lang="ts">
+
  import type { Patch } from "@app/lib/patch";
+
  import type { Project } from "@app/lib/project";
+

+
  import Authorship from "@app/components/Authorship.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import { formatObjectId } from "@app/lib/cobs";
+
  import { formatTimestamp } from "@app/lib/utils";
+

+
  export let project: Project;
+
  export let patch: Patch;
+

+
  const latestRevisionIndex = patch.revisions.length - 1;
+
  const latestRevision = patch.revisions[latestRevisionIndex];
+
  $: diffPromise = patch.getPatchDiff(
+
    project.id,
+
    latestRevision,
+
    project.seed.addr,
+
  );
+
</script>
+

+
<style>
+
  .patch-teaser {
+
    display: grid;
+
    grid-template-columns: 3rem minmax(0, 1fr) auto;
+
    padding: 0.75rem 0;
+
    background-color: var(--color-foreground-1);
+
  }
+
  .patch-teaser:hover {
+
    background-color: var(--color-foreground-2);
+
    cursor: pointer;
+
  }
+
  .meta {
+
    color: var(--color-foreground-5);
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    margin: 0 0.5rem;
+
  }
+
  .id {
+
    margin: 0;
+
  }
+
  .summary {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 0.5rem;
+
    padding-right: 2rem;
+
  }
+
  .patch-title {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .comment-count {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    padding-right: 1rem;
+
    gap: 0.5rem;
+
    color: var(--color-foreground-5);
+
  }
+

+
  .column-right {
+
    align-self: center;
+
    justify-self: center;
+
    padding-right: 1rem;
+
  }
+
  .state {
+
    justify-self: center;
+
    align-self: center;
+
  }
+
  .tags {
+
    display: flex;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
  }
+
  .tag {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+
  .state-icon {
+
    width: 0.5rem;
+
    height: 0.5rem;
+
    border-radius: var(--border-radius-small);
+
  }
+
  .highlight {
+
    color: var(--color-foreground-6);
+
  }
+
  .draft {
+
    background-color: var(--color-foreground-3);
+
  }
+
  .proposed {
+
    background-color: var(--color-positive);
+
  }
+
  .archived {
+
    background-color: var(--color-negative);
+
  }
+
  @media (max-width: 960px) {
+
    .tags {
+
      display: none;
+
    }
+
  }
+
</style>
+

+
<div class="patch-teaser">
+
  <div class="state">
+
    <div
+
      class="state-icon"
+
      class:draft={patch.state.status === "draft"}
+
      class:proposed={patch.state.status === "proposed"}
+
      class:archived={patch.state.status === "archived"} />
+
  </div>
+
  <div class="column-left">
+
    <div class="summary">
+
      <span class="patch-title">{patch.title}</span>
+
      <span class="tags">
+
        {#each patch.tags.slice(0, 4) as tag}
+
          <Badge style="max-width:7rem" variant="secondary">
+
            <span class="tag">{tag}</span>
+
          </Badge>
+
        {/each}
+
        {#if patch.tags.length > 4}
+
          <Badge variant="foreground">
+
            <span class="tag">+{patch.tags.length - 4} more tags</span>
+
          </Badge>
+
        {/if}
+
      </span>
+
    </div>
+
    <div class="summary">
+
      <span class="meta id">
+
        <span class="highlight">{formatObjectId(patch.id)}</span>
+
        opened
+
        <span class="highlight">
+
          {formatTimestamp(latestRevision.timestamp)}
+
        </span>
+
        by
+
        <Authorship highlight noAvatar author={patch.author} />
+
      </span>
+
    </div>
+
  </div>
+
  <div class="column-right">
+
    <div class="comment-count">
+
      {#await diffPromise then { diff }}
+
        <DiffStatBadge stats={diff.stats} />
+
      {/await}
+
      {#if latestRevision.discussions.length > 0}
+
        <Icon name="chat" />
+
        <span>{latestRevision.discussions.length}</span>
+
      {/if}
+
    </div>
+
  </div>
+
</div>
added src/views/projects/Patches.svelte
@@ -0,0 +1,66 @@
+
<script lang="ts" context="module">
+
  import type { PatchState } from "@app/lib/patch";
+

+
  export type PatchStatus = PatchState["status"];
+
</script>
+

+
<script lang="ts">
+
  import type { Patch } from "@app/lib/patch";
+
  import type { Project } from "@app/lib/project";
+

+
  import * as router from "@app/lib/router";
+
  import * as utils from "@app/lib/utils";
+
  import PatchTeaser from "./Patch/PatchTeaser.svelte";
+
  import Placeholder from "@app/components/Placeholder.svelte";
+

+
  export let patches: Patch[];
+
  export let status: PatchStatus;
+
  export let project: Project;
+
</script>
+

+
<style>
+
  .patches {
+
    padding: 0 2rem 0 8rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .patches-list {
+
    border-radius: var(--border-radius);
+
    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">
+
  {#if patches.length}
+
    <div class="patches-list">
+
      {#each patches 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 {project} {patch} />
+
        </div>
+
      {/each}
+
    </div>
+
  {:else}
+
    <Placeholder emoji="🍂">
+
      <div slot="title">{utils.capitalize(status)} patches</div>
+
      <div slot="body">No issues matched the current filter</div>
+
    </Placeholder>
+
  {/if}
+
</div>
modified src/views/projects/PeerSelector.svelte
@@ -1,5 +1,6 @@
<script lang="ts" strictEvents>
  import type { Peer } from "@app/lib/project";
+
  import type { Item } from "@app/components/Dropdown.svelte";

  import Badge from "@app/components/Badge.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
@@ -13,12 +14,7 @@

  let meta: Peer | undefined;

-
  let items: {
-
    key: string;
-
    value: string;
-
    title: string;
-
    badge: string | null;
-
  }[] = [];
+
  let items: Item<string>[] = [];

  function createTitle(p: Peer): string {
    const nodeId = formatNodeId(p.id);
modified src/views/projects/View.svelte
@@ -1,8 +1,10 @@
<script lang="ts">
  import type { ProjectRoute } from "@app/lib/router/definitions";
-
  import type { State as IssueState } from "./Issues.svelte";
+
  import type { IssueStatus } from "./Issues.svelte";
+
  import type { PatchStatus } 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";
@@ -18,6 +20,8 @@
  import Issues from "./Issues.svelte";
  import Message from "@app/components/Message.svelte";
  import NewIssue from "./Issue/New.svelte";
+
  import Patch from "./Patch.svelte";
+
  import Patches from "./Patches.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import ProjectMeta from "./ProjectMeta.svelte";

@@ -28,7 +32,10 @@
  $: seed = activeRoute.params.seed;

  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
-
  $: issueFilter = (searchParams.get("state") as IssueState) || "open";
+
  $: issueFilter = (searchParams.get("state") as IssueStatus) || "open";
+
  $: patchTabFilter =
+
    (searchParams.get("tab") as "activity" | "commits") || "activity";
+
  $: patchFilter = (searchParams.get("state") as PatchStatus) || "proposed";

  const getProject = async (id: string, seed: string, peer?: string) => {
    const project = await proj.Project.get(id, seed, peer);
@@ -166,7 +173,7 @@
        {#await issue.Issue.getIssues(project.id, project.seed.addr)}
          <Loading center />
        {:then issues}
-
          <Issues {project} state={issueFilter} {issues} />
+
          <Issues {project} status={issueFilter} {issues} />
        {:catch e}
          <div class="message">
            <Message error>{e.message}</Message>
@@ -182,6 +189,30 @@
            <Message error>{e.message}</Message>
          </div>
        {/await}
+
      {:else if activeRoute.params.view.resource === "patches"}
+
        {#await patch.Patch.getPatches(project.id, project.seed.addr)}
+
          <Loading center />
+
        {:then patches}
+
          <Patches {patches} status={patchFilter} {project} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "patch"}
+
        {#await patch.Patch.getPatch(project.id, activeRoute.params.view.params.patch, project.seed.addr)}
+
          <Loading center />
+
        {:then patch}
+
          <Patch
+
            {project}
+
            revision={activeRoute.params.view.params.revision}
+
            currentTab={patchTabFilter}
+
            {patch} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
      {:else}
        {unreachable(activeRoute.params.view)}
      {/if}