Add board and stack management for 0.3.0
Some checks are pending
Validate Decky / web-client (push) Waiting to run

This commit is contained in:
Elijah 2026-07-19 19:03:18 -07:00
parent dfb04462f3
commit 6a8c6a1eb6
16 changed files with 364 additions and 92 deletions

View file

@ -52,12 +52,17 @@
let dragOriginStacks: Stack[] | null = null;
let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
let reduceMotion = false;
let currentVersion = '0.2.0';
let currentVersion = '0.3.0';
let pendingUpdate: Update | null = null;
let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
let updateProgress = 0;
let editor: { mode: 'create' | 'edit'; stackId: number; card?: Card; draft: CardDraft } | null = null;
let structureEditor:
| { kind: 'board'; mode: 'create' | 'edit'; id?: number; title: string; color: string }
| { kind: 'stack'; mode: 'create' | 'edit'; id?: number; title: string; order: number }
| null = null;
let pendingDeletion: { kind: 'board' | 'stack'; id: number; title: string; cardCount: number } | null = null;
const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
const [sendCard, receiveCard] = crossfade({
duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
@ -222,6 +227,103 @@
}
}
function canEdit(board: Board | null): boolean {
return Boolean(board) && (board?.permissions?.PERMISSION_EDIT ?? true);
}
function canManage(board: Board | null): boolean {
return Boolean(board) && (board?.permissions?.PERMISSION_MANAGE ?? true);
}
function openCreateBoard() {
colorPickerOpen = false;
structureEditor = { kind: 'board', mode: 'create', title: '', color: '0082C9' };
}
function openEditBoard(board: Board) {
colorPickerOpen = false;
structureEditor = { kind: 'board', mode: 'edit', id: board.id, title: board.title, color: normalizeColor(board.color) };
}
function openCreateStack() {
if (!selectedBoard) return;
const order = stacks.length ? Math.max(...stacks.map((stack) => stack.order)) + 1 : 0;
structureEditor = { kind: 'stack', mode: 'create', title: '', order };
}
function openEditStack(stack: Stack) {
structureEditor = { kind: 'stack', mode: 'edit', id: stack.id, title: stack.title, order: stack.order };
}
async function saveStructure() {
if (!client || !structureEditor || !structureEditor.title.trim()) return;
const draft = structureEditor;
busy = true;
try {
if (draft.kind === 'board') {
const saved = draft.mode === 'create'
? await client.createBoard(draft.title.trim(), draft.color)
: await client.updateBoard(draft.id!, draft.title.trim(), draft.color);
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
const board = boards.find((item) => item.id === saved.id || item.id === draft.id);
structureEditor = null;
colorPickerOpen = false;
if (board) await selectBoard(board);
} else if (selectedBoard) {
if (draft.mode === 'create') await client.createStack(selectedBoard.id, draft.title.trim(), draft.order);
else await client.updateStack(selectedBoard.id, draft.id!, draft.title.trim(), draft.order);
structureEditor = null;
await refreshBoard();
}
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
function requestBoardDeletion(board: Board) {
const cardCount = selectedBoard?.id === board.id
? stacks.reduce((total, stack) => total + stack.cards.length, 0)
: 0;
pendingDeletion = { kind: 'board', id: board.id, title: board.title, cardCount };
}
function requestStackDeletion(stack: Stack) {
pendingDeletion = { kind: 'stack', id: stack.id, title: stack.title, cardCount: stack.cards.length };
}
async function confirmStructureDeletion() {
if (!client || !pendingDeletion) return;
const deletion = pendingDeletion;
busy = true;
try {
if (deletion.kind === 'board') {
await client.deleteBoard(deletion.id);
const oldIndex = boards.findIndex((board) => board.id === deletion.id);
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
const boardViews = { ...settings.boardViews };
delete boardViews[deletion.id];
settings = { ...settings, boardViews, selectedBoardId: undefined };
saveSettings(settings);
pendingDeletion = null;
selectedBoard = null;
stacks = [];
const next = boards[Math.min(Math.max(oldIndex, 0), boards.length - 1)];
if (next) await selectBoard(next);
else status = 'Board deleted. Create a board to keep organizing.';
} else if (selectedBoard) {
await client.deleteStack(selectedBoard.id, deletion.id);
pendingDeletion = null;
await refreshBoard();
}
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
function setOrientation(value: BoardOrientation) {
if (!selectedBoard) return;
settings = {
@ -559,11 +661,20 @@
<div class="section-heading"><span>Boards</span><small>{boards.length}</small></div>
<nav class="board-list" aria-label="Deck boards">
{#each boards as board (board.id)}
<button class:active={selectedBoard?.id === board.id} on:click={() => selectBoard(board)}>
<span class="board-dot" style:background={cssColor(board.color)}></span>
<span>{board.title}</span>
</button>
<div class:active={selectedBoard?.id === board.id} class="board-row">
<button class="board-select" on:click={() => selectBoard(board)}>
<span class="board-dot" style:background={cssColor(board.color)}></span>
<span>{board.title}</span>
</button>
{#if canManage(board)}
<span class="board-actions">
<button title={`Edit ${board.title}`} on:click={() => openEditBoard(board)}><Pencil size={14} /></button>
<button title={`Delete ${board.title}`} on:click={() => requestBoardDeletion(board)}><Trash2 size={14} /></button>
</span>
{/if}
</div>
{/each}
{#if client}<button class="add-board" on:click={openCreateBoard}><Plus size={16} /><span>Add board</span></button>{/if}
</nav>
</div>
<div class="sidebar-tools">
@ -586,7 +697,7 @@
{#if selectedBoard}<span class="board-dot" style:background={cssColor(selectedBoard.color)}></span><strong>{selectedBoard.title}</strong>{/if}
</div>
<label class="search-box"><Search size={17} /><input bind:value={search} placeholder="Find a card" /></label>
<span>{visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'}</span>
<span class="stack-summary">{visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'}{#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Add a stack" on:click={openCreateStack}><Plus size={17} /></button>{/if}</span>
</div>
<div class:vertical={orientation === 'vertical'} class="board-canvas">
@ -601,7 +712,11 @@
>
<header class="stack-header" role="presentation" on:pointerdown={(event) => beginStackPointer(event, stack)}>
<div><h2>{stack.title}</h2><span>{stack.cards.length}</span></div>
<button class="icon-button subtle" title="Add a card" on:click={() => openCreate(stack)}><Plus size={18} /></button>
<span class="stack-actions">
{#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Edit stack" on:click={() => openEditStack(stack)}><Pencil size={15} /></button>{/if}
{#if canManage(selectedBoard)}<button class="icon-button subtle" title="Delete stack" on:click={() => requestStackDeletion(stack)}><Trash2 size={15} /></button>{/if}
{#if canEdit(selectedBoard)}<button class="icon-button subtle" title="Add a card" on:click={() => openCreate(stack)}><Plus size={18} /></button>{/if}
</span>
</header>
<div class:card-drag-target={dragKind === 'card'} class="card-list" role="list" data-stack-cards={stack.id} data-card-count={stack.cards.length}>
{#each stack.cards as card, cardIndex (card.id)}
@ -705,6 +820,67 @@
</div>
{/if}
{#if structureEditor}
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (structureEditor = null, colorPickerOpen = false)}>
<form class="modal structure-modal" on:submit|preventDefault={saveStructure}>
<header>
<div>
<small>{structureEditor.mode === 'create' ? 'CREATE' : 'EDIT'} {structureEditor.kind.toUpperCase()}</small>
<h2>{structureEditor.mode === 'create' ? `Add a ${structureEditor.kind}` : `Edit ${structureEditor.title}`}</h2>
</div>
<button type="button" class="icon-button" aria-label="Close" on:click={() => (structureEditor = null, colorPickerOpen = false)}><X size={20} /></button>
</header>
<label>{structureEditor.kind === 'board' ? 'Board name' : 'Stack name'}<input bind:value={structureEditor.title} maxlength="100" /></label>
{#if structureEditor.kind === 'board'}
<label>Board color
<span class="color-control">
<button type="button" class="color-trigger" aria-expanded={colorPickerOpen} on:click={() => (colorPickerOpen = !colorPickerOpen)}>
<span class="color-preview" style:background={cssColor(structureEditor.color)}></span>
<span>#{normalizeColor(structureEditor.color)}</span>
</button>
{#if colorPickerOpen}
<span class="color-popover structure-color-popover">
<span class="swatch-grid">
{#each colorPresets as color}
<button type="button" class:active={normalizeColor(structureEditor.color) === color} class="color-swatch" style:background={cssColor(color)} title={`#${color}`} on:click={() => { if (structureEditor?.kind === 'board') structureEditor.color = color; colorPickerOpen = false; }}>
{#if normalizeColor(structureEditor.color) === color}<Check size={16} strokeWidth={3} />{/if}
</button>
{/each}
</span>
<span class="custom-color"><span>Custom color</span><input type="color" value={cssColor(structureEditor.color)} on:input={(event) => structureEditor?.kind === 'board' && (structureEditor.color = (event.currentTarget as HTMLInputElement).value.slice(1))} /></span>
<input class="color-hex" aria-label="Hex color" bind:value={structureEditor.color} maxlength="7" />
</span>
{/if}
</span>
</label>
{/if}
<footer>
<button type="button" class="secondary-button" on:click={() => (structureEditor = null, colorPickerOpen = false)}>Cancel</button>
<button class="primary-button" disabled={busy || !structureEditor.title.trim()}>{structureEditor.mode === 'create' ? `Create ${structureEditor.kind}` : 'Save changes'}</button>
</footer>
</form>
</div>
{/if}
{#if pendingDeletion}
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (pendingDeletion = null)}>
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
<header>
<div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete “{pendingDeletion.title}”?</h2></div>
<button type="button" class="icon-button" aria-label="Close" on:click={() => (pendingDeletion = null)}><X size={20} /></button>
</header>
<p class="modal-copy">
{#if pendingDeletion.kind === 'board'}
This permanently deletes the board, all of its stacks, and every card inside it from Nextcloud.
{:else}
This permanently deletes the stack and its {pendingDeletion.cardCount} card{pendingDeletion.cardCount === 1 ? '' : 's'} from Nextcloud.
{/if}
</p>
<footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting…' : `Delete ${pendingDeletion.kind}`}</button></footer>
</div>
</div>
{/if}
{#if settingsOpen}
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (settingsOpen = false)}>
<div class="modal settings-modal">