<script lang="ts">
import { tick } from "svelte";
import { boards } from "../../stores/boards.svelte.js";
import {
describeStanding,
describeState,
itemKind,
type ProjectItem,
} from "../../api/types/github.js";
import AppIcon from "../layout/AppIcon.svelte";
import ItemDetail from "./ItemDetail.svelte";
import { remembered, stringList } from "../../utils/remember.svelte.js";
import { router } from "../../stores/router.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { opensInWorkbench } from "../../utils/open-link.js";
let board = $derived(boards.selected);
let columns = $derived(board ? boards.columns(board) : []);
let field = $derived(board ? boards.field(board) : null);
let open = $derived(boards.openItem);
let held = $derived(board ? (boards.items[board.id] ?? []).length : 0);
let shown = $derived(board ? boards.visible(board).length : 0);
let over = $state<string | null>(null);
let overCard = $state<string | null>(null);
const foldedColumns = $derived(
remembered<string[]>(`projects.folded.${board?.id ?? "none"}`, [], stringList),
);
const folded = $derived(new Set(foldedColumns.value));
let adding = $state<string | null>(null);
let addTitle = $state("");
let addUrl = $state("");
let addingColumn = $state(false);
let columnName = $state("");
let columnDescription = $state("");
let columnColor = $state("GRAY");
let removingColumn = $state<string | null>(null);
let columnNameInput = $state<HTMLInputElement>();
const COLUMN_COLORS = [
"GRAY",
"BLUE",
"GREEN",
"YELLOW",
"ORANGE",
"RED",
"PINK",
"PURPLE",
] as const;
function fold(id: string) {
const next = new Set(folded);
if (next.has(id)) next.delete(id);
else next.add(id);
foldedColumns.value = [...next].sort();
}
function start(event: DragEvent, item: ProjectItem) {
boards.dragging = item.id;
event.dataTransfer?.setData("text/plain", item.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
}
function allowDrop(event: DragEvent, columnId: string, cardId: string | null) {
if (!boards.dragging) return;
if (!field && !cardId) return;
event.preventDefault();
event.stopPropagation();
over = columnId;
overCard = cardId;
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
async function drop(event: DragEvent, columnId: string, afterCard: string | null) {
event.preventDefault();
event.stopPropagation();
const landing = afterCard;
over = null;
overCard = null;
const id = boards.dragging ?? event.dataTransfer?.getData("text/plain");
if (!id || !board) return;
const item = (boards.items[board.id] ?? []).find((candidate) => candidate.id === id);
if (!item || item.id === landing) return;
if (field) await boards.move(board, item, columnId);
if (landing !== null || afterCard === null) await boards.order(board, item, landing);
}
async function addCard(columnId: string) {
if (!board) return;
const url = addUrl.trim();
const title = addTitle.trim();
const request = url ? { url } : { title, body: "" };
if (!url && !title) return;
if (await boards.add(board, request, columnId || null)) {
addTitle = "";
addUrl = "";
adding = null;
}
}
async function addBoardColumn() {
if (!board || !columnName.trim()) return;
if (
await boards.addColumn(
board,
columnName.trim(),
columnColor,
columnDescription.trim(),
)
) {
columnName = "";
columnDescription = "";
columnColor = "GRAY";
addingColumn = false;
}
}
async function showColumnForm() {
addingColumn = true;
await tick();
columnNameInput?.focus();
}
async function removeBoardColumn(optionId: string) {
if (!board) return;
if (await boards.removeColumn(board, optionId)) {
removingColumn = null;
}
}
</script>
<div class="board-page">
<header class="page-header">
<div class="board-identity">
<h1 class="page-title">{board?.title ?? "Board"}</h1>
<p class="page-subtitle">
{#if board}
<a class="board-link" href={board.url} target="_blank" rel="noreferrer">
{board.owner} · #{board.number}<AppIcon name="link" size={10} />
</a>
<span class="dot">·</span>
{describeStanding(boards.standingOf(board))}
<span class="dot">·</span>
{shown === held ? `${held} card${held === 1 ? "" : "s"}` : `${shown} of ${held} cards`}
{#if board.closed}<span class="chip">closed</span>{/if}
{#each boards.projectsUsing(board) as project (project)}
<a
class="chip"
href={router.href({ level: "project", project })}
onclick={opensInWorkbench((beside) => workbench.openProject(project, { beside }))}
title="{project} works from this board"
>{project}</a>
{/each}
{:else}
Boards belong to an owner rather than to a repository.
{/if}
</p>
</div>
{#if boards.projects.length > 0}
<div class="page-actions">
<input
class="field-input filter"
placeholder="filter cards"
aria-label="Filter cards by title, repository, label or person"
bind:value={boards.filter}
/>
<label class="check" title="Archived cards keep their field values and stay off the board">
<input type="checkbox" bind:checked={boards.showArchived} />
archived
</label>
</div>
{/if}
</header>
{#each boards.problems as problem (problem)}
<p class="board-error">{problem}</p>
{/each}
{#if boards.error}
<p class="board-error">{boards.error}</p>
{/if}
{#if boards.loading && boards.projects.length === 0}
<p class="empty">Reading boards…</p>
{:else if boards.projects.length === 0}
<p class="empty">
No project boards. They are read from GitHub with the <code>read:project</code>
scope on the GitHub login (<code>gh auth login</code>, or <code>GITHUB_TOKEN</code>)
— a token without it sees none.
</p>
{:else if board}
{#if !field}
<p class="empty">
This board has no single-select field, so it has no columns: cards can be
reordered, not moved between.
</p>
{/if}
<div class="board-body">
<div class="columns">
{#each columns as column (column.id)}
{@const shut = folded.has(column.id)}
<section
class="column"
class:folded={shut}
class:taking={over === column.id && overCard === null}
ondragover={(event) => allowDrop(event, column.id, null)}
ondragleave={() => (over = null)}
ondrop={(event) => drop(event, column.id, null)}
>
<header class="column-header">
<button
class="column-fold"
onclick={() => fold(column.id)}
aria-expanded={!shut}
title={shut ? `Unfold ${column.name}` : `Fold ${column.name}`}
>
<AppIcon name={shut ? "chevron-right" : "chevron-down"} size={12} />
<span
class="column-name column-name--{(column.color ?? 'gray').toLowerCase()}"
title={column.description ?? column.name}
>
{column.name}
</span>
<span class="count-badge">{column.items.length}</span>
</button>
{#if !shut}
<button
class="icon-button"
title="Add a card to {column.name}"
aria-label="Add a card to {column.name}"
onclick={() => {
adding = adding === column.id ? null : column.id;
addTitle = "";
addUrl = "";
}}
>
<AppIcon name="plus" size={12} />
</button>
{#if column.id !== ""}
<button
class="icon-button"
title={field && field.options.length === 1
? "The last column cannot be removed"
: `Remove ${column.name}`}
aria-label="Remove {column.name}"
disabled={field?.options.length === 1 || boards.columnsBusy !== null}
onclick={() => (removingColumn = removingColumn === column.id ? null : column.id)}
>
<AppIcon name="trash" size={12} />
</button>
{/if}
{/if}
</header>
{#if !shut}
{#if removingColumn === column.id && board}
{@const affected = boards.columnItemCount(board, column.id)}
<div class="column-confirm" role="alert">
<strong>Remove {column.name}?</strong>
<span>
{#if affected === 0}
The column is empty.
{:else}
{affected} card{affected === 1 ? "" : "s"} will move to No status.
{/if}
</span>
<div class="button-row">
<button
class="danger"
disabled={boards.columnsBusy !== null}
onclick={() => void removeBoardColumn(column.id)}
>
{boards.columnsBusy === column.id ? "Removing…" : "Remove column"}
</button>
<button
class="secondary"
disabled={boards.columnsBusy !== null}
onclick={() => (removingColumn = null)}
>Cancel</button>
</div>
</div>
{/if}
{#if adding === column.id}
<div class="add">
<input
class="field-input"
placeholder="a draft card's title"
aria-label="A draft card's title"
bind:value={addTitle}
onkeydown={(event) => {
if (event.key === "Enter") void addCard(column.id);
if (event.key === "Escape") adding = null;
}}
/>
<input
class="field-input"
placeholder="…or the URL of an issue or PR"
aria-label="The URL of an issue or pull request to add"
bind:value={addUrl}
onkeydown={(event) => {
if (event.key === "Enter") void addCard(column.id);
if (event.key === "Escape") adding = null;
}}
/>
<div class="button-row">
<button
class="primary"
disabled={boards.moving === "new" || (!addTitle.trim() && !addUrl.trim())}
onclick={() => void addCard(column.id)}
>
Add
</button>
<button class="secondary" onclick={() => (adding = null)}>Cancel</button>
</div>
</div>
{/if}
<div class="cards">
{#each column.items as item (item.id)}
<article
class="card"
class:busy={boards.moving === item.id}
class:open={boards.openItemId === item.id}
class:archived={item.archived}
class:landing={overCard === item.id}
draggable="true"
ondragstart={(event) => start(event, item)}
ondragend={() => {
boards.dragging = null;
over = null;
overCard = null;
}}
ondragover={(event) => allowDrop(event, column.id, item.id)}
ondrop={(event) => drop(event, column.id, item.id)}
onclick={() => (boards.openItemId = item.id)}
>
<p class="card-title">{item.title}</p>
{#if item.labels.length > 0}
<div class="card-labels">
{#each item.labels.slice(0, 4) as label (label.name)}
<span class="chip" style:border-color={`#${label.color}`}>
{label.name}
</span>
{/each}
{#if item.labels.length > 4}
<span class="chip">+{item.labels.length - 4}</span>
{/if}
</div>
{/if}
<div class="card-meta">
<span class="card-kind" title={itemKind(item)}>
<AppIcon
name={itemKind(item) === "pull-request"
? "git-merge"
: itemKind(item) === "draft"
? "file-text"
: "circle-alert"}
size={11}
/>
</span>
{#if item.repository}
<span class="card-where">{item.repository}#{item.number}</span>
{:else}
<span class="card-where">draft</span>
{/if}
{#if describeState(item)}
<span class="chip chip--{describeState(item)?.split(' ')[0]}">
{describeState(item)}
</span>
{/if}
<span class="card-spacer"></span>
{#if item.comment_count > 0}
<span class="card-comments" title="{item.comment_count} comments">
<AppIcon name="message-circle" size={11} />
{item.comment_count}
</span>
{/if}
{#each item.assignees.slice(0, 3) as person (person.login)}
{#if person.avatar_url}
<img
class="card-avatar"
src={person.avatar_url}
alt={person.login}
title={person.login}
width="16"
height="16"
/>
{:else}
<span class="chip" title={person.login}>{person.login}</span>
{/if}
{/each}
</div>
</article>
{/each}
{#if column.items.length === 0}
<p class="column-empty">
{boards.dragging ? "Drop here" : "Nothing here"}
</p>
{/if}
</div>
{/if}
</section>
{/each}
{#if field}
<section class="column column-create">
{#if !addingColumn}
<button class="column-create-open" onclick={() => void showColumnForm()}>
<AppIcon name="plus" size={14} />
Add column
</button>
{:else}
<div class="column-create-form">
<label class="field">
<span class="field-label">Column name</span>
<input
class="field-input"
bind:value={columnName}
bind:this={columnNameInput}
onkeydown={(event) => {
if (event.key === "Enter") void addBoardColumn();
if (event.key === "Escape") addingColumn = false;
}}
/>
</label>
<label class="field">
<span class="field-label">Colour</span>
<select class="field-input" bind:value={columnColor}>
{#each COLUMN_COLORS as color (color)}
<option value={color}>{color.toLowerCase()}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Description</span>
<input
class="field-input"
bind:value={columnDescription}
placeholder="optional"
/>
</label>
<div class="button-row">
<button
class="primary"
disabled={!columnName.trim() || boards.columnsBusy !== null}
onclick={() => void addBoardColumn()}
>
{boards.columnsBusy === "add" ? "Adding…" : "Add column"}
</button>
<button
class="secondary"
disabled={boards.columnsBusy !== null}
onclick={() => (addingColumn = false)}
>Cancel</button>
</div>
</div>
{/if}
</section>
{/if}
</div>
{#if board && open}
<ItemDetail project={board} item={open} />
{/if}
</div>
{/if}
</div>
<style>
.board-page {
--board-column: 288px;
--board-column-shut: var(--wb-control-size-44);
--board-gap: var(--wb-spacing-12);
display: flex;
flex-direction: column;
height: 100%;
padding: var(--wb-spacing-16) 0 0 var(--wb-spacing-20);
overflow: hidden;
}
.board-identity {
min-width: 0;
}
.page-subtitle {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
flex-wrap: wrap;
}
.dot {
opacity: 0.4;
}
.board-link {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-4);
color: var(--vscode-textLink-foreground);
text-decoration: none;
}
.filter {
width: 200px;
}
.board-error {
margin: 0 var(--wb-spacing-20) var(--wb-spacing-8) 0;
padding: var(--wb-spacing-6) var(--wb-spacing-8);
font-size: var(--wb-font-size-11-5);
border-radius: var(--wsb-radius-sm);
background: color-mix(in srgb, var(--wb-error) 12%, transparent);
color: var(--wb-error);
}
.board-body {
display: flex;
min-height: 0;
min-width: 0;
flex: 1;
}
.columns {
display: flex;
gap: var(--board-gap);
align-items: flex-start;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0 var(--wb-spacing-20) var(--wb-spacing-12) 0;
}
.column {
display: flex;
flex-direction: column;
flex: none;
width: var(--board-column);
max-height: 100%;
border: 1px solid var(--vscode-panel-border, color-mix(in srgb, currentColor 18%, transparent));
border-radius: var(--wsb-radius-md);
background: color-mix(in srgb, currentColor 4%, transparent);
transition: width 120ms ease;
}
.column.folded {
width: var(--board-column-shut);
}
.column.taking {
border-color: var(--vscode-focusBorder);
background: color-mix(in srgb, var(--vscode-focusBorder) 8%, transparent);
}
.column-create {
min-height: var(--wb-control-size-48);
background: transparent;
border-style: dashed;
}
.column-create-open {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
width: 100%;
padding: var(--wb-spacing-14);
color: var(--vscode-descriptionForeground);
text-align: left;
}
.column-create-open:hover {
color: var(--vscode-foreground);
background: color-mix(in srgb, currentColor 5%, transparent);
}
.column-create-form,
.column-confirm {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-10);
}
.column-confirm {
margin: 0 var(--wb-spacing-6) var(--wb-spacing-8);
border: 1px solid color-mix(in srgb, var(--wb-error) 24%, transparent);
border-radius: var(--wsb-radius-sm);
background: color-mix(in srgb, var(--wb-error) 8%, transparent);
font-size: var(--wb-font-size-11);
line-height: 1.4;
}
.column-header {
display: flex;
align-items: center;
gap: var(--wb-spacing-4);
padding: var(--wb-spacing-6) var(--wb-spacing-6) var(--wb-spacing-6) var(--wb-spacing-4);
min-height: var(--wb-control-size-36);
}
.column-fold {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
flex: 1;
min-width: 0;
padding: var(--wb-spacing-4);
background: none;
border: none;
border-radius: var(--wsb-radius-sm);
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
}
.column-fold:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.column-name {
font-size: var(--wb-font-size-11);
font-weight: 600;
padding: var(--wb-spacing-1) var(--wb-spacing-8);
border-radius: var(--wb-corner-radius-9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column.folded .column-fold {
flex-direction: column;
align-items: center;
gap: var(--wb-spacing-8);
height: 100%;
padding: var(--wb-spacing-4) 0;
}
.column.folded .column-name {
writing-mode: vertical-rl;
padding: var(--wb-spacing-8) var(--wb-spacing-1);
max-height: 220px;
}
.cards {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-6);
padding: 0 var(--wb-spacing-6) var(--wb-spacing-8);
overflow-y: auto;
min-height: 0;
}
.add {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-6);
padding: 0 var(--wb-spacing-6) var(--wb-spacing-8);
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--wb-spacing-4);
padding: var(--wb-spacing-8) var(--wb-spacing-10);
border: 1px solid var(--vscode-panel-border, color-mix(in srgb, currentColor 15%, transparent));
border-radius: var(--wsb-radius-sm);
background: var(--vscode-editor-background);
cursor: grab;
}
.card:hover {
border-color: color-mix(in srgb, var(--vscode-focusBorder) 60%, transparent);
box-shadow: var(--wb-shadow-sm);
}
.card:active {
cursor: grabbing;
}
.card.open {
border-color: var(--vscode-focusBorder);
box-shadow: inset 2px 0 0 var(--vscode-focusBorder);
}
.card.archived {
opacity: 0.55;
}
.card.busy {
opacity: 0.6;
cursor: progress;
}
.card.landing::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: var(--wb-spacing-neg-4);
height: var(--wb-control-size-2);
border-radius: var(--wb-corner-radius-1);
background: var(--vscode-focusBorder);
}
.card-title {
margin: 0;
font-size: var(--wb-font-size-12);
line-height: 1.4;
color: var(--vscode-editor-foreground);
overflow-wrap: anywhere;
}
.card-labels {
display: flex;
flex-wrap: wrap;
gap: var(--wb-spacing-3);
}
.card-meta {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
font-size: var(--wb-font-size-10-5);
color: var(--vscode-descriptionForeground);
}
.card-spacer {
flex: 1;
}
.card-kind,
.card-comments {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-3);
flex: none;
}
.card-where {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-avatar {
width: var(--wb-icon-size-16);
height: var(--wb-icon-size-16);
border-radius: 50%;
flex: none;
}
.column-empty {
margin: var(--wb-spacing-4) var(--wb-spacing-2) var(--wb-spacing-8);
padding: var(--wb-spacing-10);
text-align: center;
border: 1px dashed color-mix(in srgb, currentColor 20%, transparent);
border-radius: var(--wsb-radius-sm);
}
code {
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-10-5);
}
.column-name--gray { background: var(--wb-gh-gray); color: var(--wb-gh-on-color); }
.column-name--green { background: var(--wb-gh-green); color: var(--wb-gh-on-color); }
.column-name--yellow { background: var(--wb-gh-yellow); color: var(--wb-gh-on-color); }
.column-name--orange { background: var(--wb-gh-orange); color: var(--wb-gh-on-color); }
.column-name--red { background: var(--wb-gh-red); color: var(--wb-gh-on-color); }
.column-name--purple { background: var(--wb-gh-purple); color: var(--wb-gh-on-color); }
.column-name--blue { background: var(--wb-gh-blue); color: var(--wb-gh-on-color); }
.column-name--pink { background: var(--wb-gh-pink); color: var(--wb-gh-on-color); }
</style>