Add board and stack management for 0.3.0
Some checks are pending
Validate Decky / web-client (push) Waiting to run
Some checks are pending
Validate Decky / web-client (push) Waiting to run
This commit is contained in:
parent
dfb04462f3
commit
6a8c6a1eb6
16 changed files with 364 additions and 92 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue