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
|
|
@ -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"
|
||||
34
.forgejo/workflows/validate.yml
Normal file
34
.forgejo/workflows/validate.yml
Normal 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
|
||||
11
README.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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
4
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "decky",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --configLoader runner",
|
||||
|
|
|
|||
2
app/src-tauri/Cargo.lock
generated
2
app/src-tauri/Cargo.lock
generated
|
|
@ -590,7 +590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "decky"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue