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

@ -1,43 +0,0 @@
name: Release Decky
on:
push:
tags:
- 'v*'
jobs:
windows-release:
# This label must match a trusted Windows Forgejo runner with Node, Rust,
# Visual Studio C++ Build Tools, and WebView2/NSIS build prerequisites.
runs-on: windows-latest
steps:
- name: Check out the tagged source
uses: https://data.forgejo.org/actions/checkout@v6
- name: Set up Node.js
uses: https://data.forgejo.org/actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: app/package-lock.json
- name: Set up Rust
uses: https://github.com/dtolnay/rust-toolchain@stable
- name: Build signed installer and updater metadata
shell: pwsh
working-directory: .
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
$version = '${{ forgejo.ref_name }}'.TrimStart('v')
./scripts/Prepare-Release.ps1 -Version $version -Notes "See the Forgejo release for changes in Decky $version."
- name: Publish Forgejo release and stable update metadata
shell: pwsh
env:
FORGEJO_TOKEN: ${{ forgejo.token }}
run: |
$version = '${{ forgejo.ref_name }}'.TrimStart('v')
./scripts/Publish-ForgejoRelease.ps1 -Version $version -Notes "Decky $version"

View file

@ -0,0 +1,34 @@
name: Validate Decky
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
web-client:
# This matches the standard Docker label used by Forgejo Runner. If the
# Unraid runner advertises a different label, change only this value.
runs-on: docker
container:
image: node:22-bookworm
steps:
- name: Check out source
uses: https://data.forgejo.org/actions/checkout@v6
- name: Install locked dependencies
working-directory: app
run: npm ci
- name: Validate Svelte and TypeScript
working-directory: app
run: npm run check
- name: Run unit tests
working-directory: app
run: npm test
- name: Build web assets
working-directory: app
run: npm run build

View file

@ -18,6 +18,8 @@ Implemented functionality includes:
- app-password authentication and automatic API-path discovery;
- active-board filtering and saved board selection;
- board creation, renaming, recoloring, and deletion;
- stack creation, renaming, reordering, and deletion;
- horizontal and vertical layouts saved independently per board;
- card creation, editing, deletion, due date, description, and color controls;
- a directly visible completion checkmark that synchronizes Deck's `done` value
@ -46,7 +48,7 @@ npm run tauri dev
Build a release executable with:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.2.0 -Notes "Release notes"
powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.3.0 -Notes "Release notes"
```
This produces a normal per-user Windows setup executable plus the cryptographic
@ -65,7 +67,8 @@ OS credential service.
## Updates
Installed copies update from signed release artifacts, never with `git pull`.
Installed copies update from signed Forgejo Release artifacts, never with `git pull`.
The updater is available from Settings and reads stable metadata from the
Forgejo repository. See `app/UPDATES.md` for signing-key backup, release tags,
Forgejo Actions secrets, and the manual release fallback.
Forgejo repository. The Unraid Forgejo runner validates source changes; this
Windows machine builds and signs the installer. See `app/UPDATES.md` for the
complete release procedure and signing-key backup.

View file

@ -38,45 +38,54 @@ are attached to immutable Forgejo releases such as `v0.2.0`.
The repository and release assets must be readable without authentication.
Tauri cannot attach the user's Forgejo credentials to an updater request.
## Preparing a release locally
## Hybrid Windows + Forgejo release flow
Keep all three version fields synchronized with `Set-Version.ps1`, commit the
release, and tag that exact commit:
The Unraid Forgejo runner is a Linux container. It validates the Svelte and
TypeScript client on every push, but it does not build the native Windows/MSVC
installer. Build and sign Windows releases on this trusted Windows machine.
Keep all three version fields synchronized, commit and push the source, then
prepare the installer from that same commit:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\Set-Version.ps1 -Version 0.2.1
powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.2.1 -Notes "Release notes"
git tag v0.2.1
git push origin main v0.2.1
powershell -ExecutionPolicy Bypass -File .\scripts\Set-Version.ps1 -Version 0.3.0
git add app/package.json app/package-lock.json app/src-tauri/Cargo.toml app/src-tauri/tauri.conf.json
git commit -m "release: Decky 0.3.0"
git push origin main
powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.3.0 -Notes "Board and stack management"
```
The preparation script runs dependency installation, validation, tests, the
Tauri build, and creates:
```text
artifacts/release/v0.2.1/
Decky_0.2.1_x64-setup.exe
Decky_0.2.1_x64-setup.exe.sig
artifacts/release/v0.3.0/
Decky_0.3.0_x64-setup.exe
Decky_0.3.0_x64-setup.exe.sig
latest.json
```
To publish manually, set `FORGEJO_TOKEN` only in the current process and run:
Installer files do not belong in Git. To publish them, set the same personal
access token value used for the `FORGEJO_PAT` repository secret only in the
current PowerShell process and run:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\Publish-ForgejoRelease.ps1 -Version 0.2.1 -Notes "Release notes"
$env:FORGEJO_PAT = 'paste-token-for-this-session'
powershell -ExecutionPolicy Bypass -File .\scripts\Publish-ForgejoRelease.ps1 -Version 0.3.0 -Notes "Board and stack management"
Remove-Item Env:FORGEJO_PAT
git pull --ff-only
```
The publisher creates the Forgejo release, uploads all three assets, and updates
the tracked `releases/latest.json` through the Forgejo API.
The publisher creates the `v0.3.0` Forgejo release/tag at `main`, uploads the
installer, signature, and metadata, then commits the tracked
`releases/latest.json` through the Forgejo API. The final pull synchronizes that
small metadata commit locally. Existing Decky installations then discover the
release through Settings.
## Forgejo Actions
`.forgejo/workflows/release.yml` performs the same process when a `v*` tag is
pushed. Configure a trusted Windows runner whose label matches
`windows-latest`, then add these repository secrets:
- `TAURI_SIGNING_PRIVATE_KEY`: contents of `decky.key`
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: contents of `decky.key.password`
The short-lived `${{ forgejo.token }}` publishes only to this repository. The
workflow intentionally runs only for trusted tags, never pull requests.
`.forgejo/workflows/validate.yml` uses the existing Docker runner to install
locked dependencies, type-check, test, and build the web client. The repository
secret is intentionally not exposed to validation jobs. If the runner uses a
custom label instead of `docker`, update the workflow's `runs-on` value to the
label shown on Forgejo's runner page.

4
app/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "decky",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "decky",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@lucide/svelte": "^0.545.0",
"@tauri-apps/api": "^2.0.0",

View file

@ -1,7 +1,7 @@
{
"name": "decky",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",

View file

@ -590,7 +590,7 @@ dependencies = [
[[package]]
name = "decky"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"keyring",
"serde",

View file

@ -1,6 +1,6 @@
[package]
name = "decky"
version = "0.2.0"
version = "0.3.0"
description = "A focused cross-platform client for Nextcloud Deck"
authors = ["Elijah Kuntz"]
edition = "2021"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Decky",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "com.elijahkuntz.decky",
"build": {
"beforeDevCommand": "npm run dev",
@ -34,7 +34,7 @@
"category": "Productivity",
"shortDescription": "Nextcloud Deck on your desktop",
"longDescription": "A focused cross-platform desktop client for viewing and editing Nextcloud Deck boards.",
"copyright": "Copyright © 2026 Elijah Kuntz",
"copyright": "Copyright © 2026 Elijah Kuntz",
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"

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 {

View file

@ -8,11 +8,11 @@ param(
[string]$Notes = "Decky $Version",
[string]$ForgejoBaseUrl = 'https://git.elijahkuntz.com',
[string]$Repository = 'Elijah/Decky',
[string]$Token = $env:FORGEJO_TOKEN
[string]$Token = $(if ($env:FORGEJO_TOKEN) { $env:FORGEJO_TOKEN } else { $env:FORGEJO_PAT })
)
$ErrorActionPreference = 'Stop'
if (-not $Token) { throw 'FORGEJO_TOKEN is required. Store it as a Forgejo Actions secret or set it only for this process.' }
if (-not $Token) { throw 'FORGEJO_TOKEN or FORGEJO_PAT is required. Set it only for this PowerShell process.' }
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$releaseDirectory = Join-Path $repositoryRoot "artifacts\release\v$Version"

View file

@ -28,4 +28,9 @@ $cargoManifest = Get-Content -Raw -LiteralPath $cargoManifestPath
$cargoManifest = [regex]::Replace($cargoManifest, '(?m)(^version\s*=\s*")[^"]+("\s*$)', "`${1}$Version`${2}", 1)
[IO.File]::WriteAllText($cargoManifestPath, $cargoManifest, (New-Object Text.UTF8Encoding($false)))
Write-Host "Decky version set to $Version in npm, Tauri, and Cargo manifests."
$appSourcePath = Join-Path $appRoot 'src\App.svelte'
$appSource = Get-Content -Raw -LiteralPath $appSourcePath
$appSource = [regex]::Replace($appSource, "(?m)(let currentVersion = ')[^']+(';)", "`${1}$Version`${2}", 1)
[IO.File]::WriteAllText($appSourcePath, $appSource, (New-Object Text.UTF8Encoding($false)))
Write-Host "Decky version set to $Version in npm, Tauri, Cargo, and the browser-preview fallback."