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">

View file

@ -81,19 +81,30 @@ textarea { resize: vertical; min-height: 90px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-weight: 450; }
.checkbox-row input { width: 15px; height: 15px; accent-color: var(--accent); }
.primary-button, .secondary-button { border: 0; border-radius: 18px; min-height: 36px; padding: 0 17px; font-weight: 650; cursor: pointer; }
.primary-button, .secondary-button, .danger-button { border: 0; border-radius: 18px; min-height: 36px; padding: 0 17px; font-weight: 650; cursor: pointer; }
.primary-button { color: white; background: var(--accent); }
.primary-button:hover:not(:disabled) { background: #1595d3; }
.primary-button:disabled { opacity: .42; cursor: default; }
.secondary-button { color: #e1e1e1; background: var(--panel-3); }
.danger-button { color: #fff; background: #d33c43; }
.danger-button:hover:not(:disabled) { background: #e24b52; }
.danger-button:disabled { opacity: .45; cursor: default; }
.section-heading { display: flex; align-items: center; justify-content: space-between; margin: 18px 8px 7px; color: #b5b5b5; font-size: 11px; font-weight: 700; letter-spacing: .035em; text-transform: uppercase; }
.section-heading small { min-width: 22px; padding: 1px 6px; border-radius: 10px; text-align: center; background: var(--panel-3); }
.board-list { display: grid; gap: 2px; }
.board-list button { position: relative; width: 100%; display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; padding: 9px 10px; border: 0; border-radius: 5px; background: transparent; text-align: left; cursor: pointer; color: #d0d0d0; }
.board-list button:hover { background: rgba(255,255,255,.055); color: #fff; }
.board-list button.active { color: #fff; background: rgba(255,255,255,.09); }
.board-list button.active::before { content: ''; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 2px; background: var(--accent); }
.board-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; border-radius: 5px; color: #d0d0d0; }
.board-row:hover { color: #fff; background: rgba(255,255,255,.055); }
.board-row.active { color: #fff; background: rgba(255,255,255,.09); }
.board-row.active::before { content: ''; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 2px; background: var(--accent); }
.board-select { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: center; padding: 9px 8px 9px 10px; border: 0; border-radius: 5px; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.board-select span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.board-actions { display: flex; padding-right: 4px; opacity: 0; transition: opacity .15s; }
.board-row:hover .board-actions, .board-row:focus-within .board-actions { opacity: 1; }
.board-actions button { width: 27px; height: 27px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 5px; color: var(--muted); background: transparent; cursor: pointer; }
.board-actions button:hover { color: #fff; background: rgba(255,255,255,.1); }
.add-board { display: flex; align-items: center; gap: 9px; margin-top: 5px; padding: 8px 10px; border: 0; border-radius: 5px; color: var(--muted); background: transparent; cursor: pointer; }
.add-board:hover { color: #fff; background: rgba(255,255,255,.055); }
.board-dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); flex: 0 0 auto; }
.sidebar-tools { display: grid; gap: 3px; padding: 8px; border-top: 1px solid var(--line); }
@ -115,6 +126,7 @@ textarea { resize: vertical; min-height: 90px; }
.search-box { width: 100%; position: relative; display: flex; align-items: center; color: var(--muted); }
.search-box svg { position: absolute; left: 10px; }
.search-box input { height: 32px; padding: 6px 10px 6px 34px; background: #191919; }
.stack-summary { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
.board-canvas { flex: 1 1 auto; min-height: 0; display: flex; align-items: flex-start; gap: 14px; overflow: auto; padding: 14px 18px 24px; }
.board-canvas.vertical { flex-direction: column; align-items: stretch; overflow-x: hidden; overflow-y: auto; }
@ -127,6 +139,8 @@ textarea { resize: vertical; min-height: 90px; }
.stack-header > div { min-width: 0; display: flex; align-items: center; gap: 8px; }
.stack-header h2 { margin: 0; overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; }
.stack-header span { color: var(--muted); font-size: 11px; }
.stack-actions { display: flex; align-items: center; opacity: .72; transition: opacity .15s; }
.stack:hover .stack-actions, .stack:focus-within .stack-actions { opacity: 1; }
.icon-button.subtle { width: 31px; height: 31px; }
.card-list { min-height: 58px; overflow-y: auto; display: grid; align-content: start; gap: 7px; padding: 8px; }
.vertical .card-list { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); overflow: visible; }
@ -167,6 +181,8 @@ textarea { resize: vertical; min-height: 90px; }
.modal-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.7); backdrop-filter: blur(5px); }
.modal { width: min(560px, 100%); max-height: calc(100vh - 40px); overflow: visible; display: grid; gap: 17px; padding: 20px; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; background: #282828; box-shadow: 0 24px 70px rgba(0,0,0,.52); }
.settings-modal { width: min(680px, 100%); max-height: calc(100vh - 40px); overflow-y: auto; }
.structure-modal { width: min(440px, 100%); }
.confirm-modal { width: min(460px, 100%); }
.settings-section { display: grid; gap: 14px; padding-top: 17px; border-top: 1px solid var(--line); }
.settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.settings-heading h3 { margin: 0 0 4px; font-size: 15px; }

View file

@ -23,6 +23,43 @@ describe('DeckApiClient', () => {
expect(client.apiBaseUrl.toString()).toBe('https://cloud.example.test/apps/deck/api/v1.0/');
});
it('creates, updates, and deletes boards with Deck-compatible payloads', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input, init) => {
if (String(input).endsWith('/boards') && !init?.method) return json([]);
return init?.method === 'DELETE' ? new Response(null, { status: 204 }) : json({ id: 4, title: 'Projects', color: '0082C9' });
});
const client = await DeckApiClient.connect(options, fetchImpl);
await client.createBoard('Projects', '#0082c9');
await client.updateBoard(4, 'Projects 2026', '46BA61');
await client.deleteBoard(4);
expect(String(fetchImpl.mock.calls[1][0])).toMatch(/\/boards$/);
expect(fetchImpl.mock.calls[1][1]?.method).toBe('POST');
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({ title: 'Projects', color: '0082C9' });
expect(String(fetchImpl.mock.calls[2][0])).toMatch(/\/boards\/4$/);
expect(JSON.parse(String(fetchImpl.mock.calls[2][1]?.body))).toEqual({ title: 'Projects 2026', color: '46BA61', archived: false });
expect(fetchImpl.mock.calls[3][1]?.method).toBe('DELETE');
});
it('creates, renames, and deletes stacks while preserving order', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input, init) => {
if (String(input).endsWith('/boards') && !init?.method) return json([]);
return init?.method === 'DELETE' ? new Response(null, { status: 204 }) : json({ id: 9, boardId: 4, title: 'Next', order: 3, cards: [] });
});
const client = await DeckApiClient.connect(options, fetchImpl);
await client.createStack(4, 'Next', 3);
await client.updateStack(4, 9, 'Later', 3);
await client.deleteStack(4, 9);
expect(String(fetchImpl.mock.calls[1][0])).toMatch(/\/boards\/4\/stacks$/);
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({ title: 'Next', order: 3 });
expect(String(fetchImpl.mock.calls[2][0])).toMatch(/\/boards\/4\/stacks\/9$/);
expect(JSON.parse(String(fetchImpl.mock.calls[2][1]?.body))).toEqual({ title: 'Later', order: 3 });
expect(fetchImpl.mock.calls[3][1]?.method).toBe('DELETE');
});
it('preserves complete card fields and normalizes object owners', () => {
const card: Card = {
id: 1,

View file

@ -106,10 +106,35 @@ export class DeckApiClient {
return this.request<Board[]>('boards');
}
createBoard(title: string, color: string): Promise<Board> {
return this.request<Board>('boards', {
method: 'POST',
body: JSON.stringify({ title, color: normalizeColor(color) })
});
}
updateBoard(boardId: number, title: string, color: string, archived = false): Promise<Board> {
return this.request<Board>(`boards/${boardId}`, {
method: 'PUT',
body: JSON.stringify({ title, color: normalizeColor(color), archived })
});
}
deleteBoard(boardId: number): Promise<void> {
return this.request<void>(`boards/${boardId}`, { method: 'DELETE' });
}
getStacks(boardId: number): Promise<Stack[]> {
return this.request<Stack[]>(`boards/${boardId}/stacks`);
}
createStack(boardId: number, title: string, order: number): Promise<Stack> {
return this.request<Stack>(`boards/${boardId}/stacks`, {
method: 'POST',
body: JSON.stringify({ title, order })
});
}
updateStack(boardId: number, stackId: number, title: string, order: number): Promise<Stack> {
return this.request<Stack>(`boards/${boardId}/stacks/${stackId}`, {
method: 'PUT',
@ -117,6 +142,10 @@ export class DeckApiClient {
});
}
deleteStack(boardId: number, stackId: number): Promise<void> {
return this.request<void>(`boards/${boardId}/stacks/${stackId}`, { method: 'DELETE' });
}
async reorderStack(boardId: number, stackId: number, order: number): Promise<Stack[]> {
const server = normalizeServerUrl(this.options.serverUrl);
const url = new URL(`ocs/v2.php/apps/deck/api/v1.0/stacks/${stackId}/reorder`, server);

View file

@ -14,6 +14,12 @@ export interface Board {
archived?: boolean;
lastModified?: number;
deletedAt?: number;
permissions?: {
PERMISSION_READ?: boolean;
PERMISSION_EDIT?: boolean;
PERMISSION_MANAGE?: boolean;
PERMISSION_SHARE?: boolean;
};
}
export interface Stack {