From 6a8c6a1eb68baf3ec9be359b954c1796a4dc38ad Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 19 Jul 2026 19:03:18 -0700 Subject: [PATCH] Add board and stack management for 0.3.0 --- .forgejo/workflows/release.yml | 43 ------- .forgejo/workflows/validate.yml | 34 ++++++ README.md | 11 +- app/UPDATES.md | 55 +++++---- app/package-lock.json | 4 +- app/package.json | 2 +- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 4 +- app/src/App.svelte | 190 +++++++++++++++++++++++++++-- app/src/app.css | 26 +++- app/src/lib/deck-api.test.ts | 37 ++++++ app/src/lib/deck-api.ts | 29 +++++ app/src/lib/types.ts | 6 + scripts/Publish-ForgejoRelease.ps1 | 4 +- scripts/Set-Version.ps1 | 7 +- 16 files changed, 364 insertions(+), 92 deletions(-) delete mode 100644 .forgejo/workflows/release.yml create mode 100644 .forgejo/workflows/validate.yml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 09c1a6c..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -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" diff --git a/.forgejo/workflows/validate.yml b/.forgejo/workflows/validate.yml new file mode 100644 index 0000000..82e2052 --- /dev/null +++ b/.forgejo/workflows/validate.yml @@ -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 diff --git a/README.md b/README.md index 9ea7e8f..afab0fb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/UPDATES.md b/app/UPDATES.md index a3ad2b4..64b5c11 100644 --- a/app/UPDATES.md +++ b/app/UPDATES.md @@ -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. diff --git a/app/package-lock.json b/app/package-lock.json index f398c3b..9edc841 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index fa541ae..91c90a6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "decky", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite --configLoader runner", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 5c0ae0f..d556937 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -590,7 +590,7 @@ dependencies = [ [[package]] name = "decky" -version = "0.2.0" +version = "0.3.0" dependencies = [ "keyring", "serde", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index d6fb600..3d172b1 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 8658d89..3a07518 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -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" diff --git a/app/src/App.svelte b/app/src/App.svelte index 6628677..906a5d6 100644 --- a/app/src/App.svelte +++ b/app/src/App.svelte @@ -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 @@
Boards{boards.length}
- {visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'} + {visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'}{#if canEdit(selectedBoard)}{/if}
@@ -601,7 +712,11 @@ >
{#each stack.cards as card, cardIndex (card.id)} @@ -705,6 +820,67 @@
{/if} +{#if structureEditor} + +{/if} + +{#if pendingDeletion} + +{/if} + {#if settingsOpen}