Initial commit of version 0.2.0

This commit is contained in:
Elijah 2026-07-19 18:37:14 -07:00
parent 86fc06b877
commit dfb04462f3
108 changed files with 16167 additions and 80 deletions

View file

@ -0,0 +1,43 @@
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"

5
.gitignore vendored
View file

@ -10,3 +10,8 @@ artifacts/
appsettings.local.json appsettings.local.json
.dotnet-home/ .dotnet-home/
.packages/ .packages/
app/node_modules/
app/dist/
app/src-tauri/target/
app/src-tauri/release.conf.json
.tauri-signing/

View file

@ -1,5 +1,13 @@
# Implementation plan: Nextcloud Deck desktop client # Implementation plan: Nextcloud Deck desktop client
> **Current architecture (July 19, 2026):** The original WinUI/MSIX proposal
> below is retained as historical design context. The implemented application
> uses Tauri 2, Svelte 5, and TypeScript so the same UI and Deck integration can
> ship on Windows, macOS, and Linux. Windows version 0.2.0 is packaged as a
> per-user NSIS installer and uses Tauri's signed updater with immutable Forgejo
> release assets plus `releases/latest.json`. The application never updates by
> pulling the Git repository. `app/UPDATES.md` is authoritative for releases.
## Architecture review (July 19, 2026) ## Architecture review (July 19, 2026)
The overall direction remains appropriate: a C# WinUI 3 client, an API-first core, The overall direction remains appropriate: a C# WinUI 3 client, an API-first core,

View file

@ -1,41 +1,71 @@
# Decky # Decky
Decky is a focused Windows desktop client for viewing and editing boards from a Decky is a focused cross-platform desktop client for viewing and editing boards
Nextcloud Deck instance. from a Nextcloud Deck instance.
## Current status ## Current application
The repository currently contains: The active application lives in `app/` and uses:
- `Decky.Core`, a UI-independent Deck REST API client and domain foundation; - Tauri 2 for the small native desktop shell;
- `Decky.ApiSpike`, a read-only connection and compatibility probe; - Svelte 5 and TypeScript for the interface;
- `Decky.App`, the initial WinUI 3 desktop shell; - the operating system webview on Windows, macOS, and Linux;
- focused tests for API path discovery, required headers, update serialization, - the native credential store on each platform (Windows Credential Manager,
completion clearing, color preservation, and text contrast. macOS Keychain, or Linux Secret Service);
- Tauri's Rust HTTP client so Nextcloud requests are not blocked by browser CORS.
Implemented functionality includes:
- app-password authentication and automatic API-path discovery;
- active-board filtering and saved board selection;
- 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
and applies the configured active/completed color;
- preserved full-card update payloads and the tested Deck cross-stack movement
compatibility fallback;
- responsive/collapsible navigation and card search.
The earlier .NET/WinUI projects remain in `src/` and `tests/` temporarily as a
reference for the compatibility probe and original API test cases. They are no
longer the primary application.
## Development ## Development
Install the .NET 10 SDK and the Visual Studio WinUI application-development Install Node.js, Rust stable, the platform-specific Tauri prerequisites, and
workload. Then run: then run:
```powershell ```powershell
dotnet restore Decky.slnx cd app
dotnet test tests/Decky.Core.Tests/Decky.Core.Tests.csproj npm install
dotnet run --project src/Decky.ApiSpike/Decky.ApiSpike.csproj npm run check
npm test
npm run tauri dev
``` ```
The compatibility probe prompts for a Nextcloud app password without echoing or Build a release executable with:
storing it. It starts with read-only board, stack, and card-list requests. On a
disposable test board, its explicitly confirmed `WRITE` mode also verifies card
color creation/preservation, setting and clearing completion, same-stack reorder,
cross-stack movement, response verification, and cleanup.
The desktop app stores its JSON settings in `%LOCALAPPDATA%\Decky\settings.json`. ```powershell
The app password is stored separately in Windows Credential Locker and is never powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.2.0 -Notes "Release notes"
written to the JSON file. ```
This produces a normal per-user Windows setup executable plus the cryptographic
signature and metadata used by Decky's in-app updater.
Cross-platform artifacts must be compiled on their target operating system.
Use macOS for `.app`/DMG builds, Windows for MSI/NSIS builds, and Linux for the
desired Linux packages.
## Security
Decky only accepts HTTPS servers, except for `localhost` during development.
The app password is never stored in local storage or the JSON/settings model.
When “Save securely on this device” is selected it is saved through the native
OS credential service.
## Updates ## Updates
Release builds will be distributed as a signed MSIX bundle through a stable Installed copies update from signed release artifacts, never with `git pull`.
`Decky.appinstaller` feed. Forgejo is the source and CI/release host; installed The updater is available from Settings and reads stable metadata from the
copies do not update with `git pull`. Forgejo repository. See `app/UPDATES.md` for signing-key backup, release tags,
Forgejo Actions secrets, and the manual release fallback.

82
app/UPDATES.md Normal file
View file

@ -0,0 +1,82 @@
# Installer and signed updates
Decky is distributed on Windows as a per-user Tauri NSIS installer. Installed
copies update from signed Forgejo release assets; they never run `git pull`.
## Trust model
Two signing systems are independent:
- The Tauri updater signature is mandatory. It proves an update was made with
Decky's updater private key before the installer is executed.
- Windows Authenticode signing is optional but recommended for wider
distribution because it reduces SmartScreen warnings. It requires a separate
code-signing certificate and is not configured yet.
Decky's permanent updater key is stored outside the repository:
```text
%USERPROFILE%\.tauri\decky.key
%USERPROFILE%\.tauri\decky.key.password
```
Back up both files securely. Never regenerate this key after shipping: existing
installations would reject every artifact signed by a replacement key.
## Forgejo layout
The installed app checks this stable, public HTTPS URL:
```text
https://git.elijahkuntz.com/Elijah/Decky/raw/branch/main/releases/latest.json
```
`releases/latest.json` contains the newest semantic version, release notes,
versioned installer URL, and the contents of its `.sig` file. Installer assets
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
Keep all three version fields synchronized with `Set-Version.ps1`, commit the
release, and tag that exact 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
```
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
latest.json
```
To publish manually, set `FORGEJO_TOKEN` only in the current process and run:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\Publish-ForgejoRelease.ps1 -Version 0.2.1 -Notes "Release notes"
```
The publisher creates the Forgejo release, uploads all three assets, and updates
the tracked `releases/latest.json` through the Forgejo API.
## 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.

13
app/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#11151d" />
<title>Decky</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2736
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
app/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "decky",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",
"build": "vite build --configLoader runner",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"tauri": "tauri"
},
"dependencies": {
"@lucide/svelte": "^0.545.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.0.0",
"svelte": "^5.0.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.0",
"jsdom": "^26.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^7.0.0",
"vitest": "^3.0.0"
}
}

5348
app/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
app/src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "decky"
version = "0.2.0"
description = "A focused cross-platform client for Nextcloud Deck"
authors = ["Elijah Kuntz"]
edition = "2021"
[lib]
name = "decky_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sync-secret-service"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-http = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"

3
app/src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,23 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions required by the Decky main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"process:allow-restart",
"updater:default",
{
"identifier": "http:default",
"allow": [
{ "url": "https://**" },
{ "url": "http://localhost:**" },
{ "url": "http://127.0.0.1:**" }
]
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Permissions required by the Decky main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-start-dragging","process:allow-restart","updater:default",{"identifier":"http:default","allow":[{"url":"https://**"},{"url":"http://localhost:**"},{"url":"http://127.0.0.1:**"}]}]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="80" y1="48" x2="432" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#A497FF"/>
<stop offset="1" stop-color="#6757D9"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="116" fill="#11151D"/>
<rect x="70" y="70" width="372" height="372" rx="88" fill="url(#bg)"/>
<rect x="132" y="145" width="104" height="222" rx="27" fill="white" fill-opacity=".96"/>
<rect x="276" y="145" width="104" height="138" rx="27" fill="white" fill-opacity=".96"/>
<path d="m298 332 24 24 50-57" fill="none" stroke="white" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

45
app/src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,45 @@
const CREDENTIAL_SERVICE: &str = "com.elijahkuntz.decky.nextcloud";
fn credential_entry(server: &str, username: &str) -> Result<keyring::Entry, String> {
let account = format!("{}|{}", server.trim().to_lowercase(), username.trim());
keyring::Entry::new(CREDENTIAL_SERVICE, &account).map_err(|error| error.to_string())
}
#[tauri::command]
fn get_credential(server: String, username: String) -> Result<Option<String>, String> {
match credential_entry(&server, &username)?.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(error.to_string()),
}
}
#[tauri::command]
fn set_credential(server: String, username: String, password: String) -> Result<(), String> {
credential_entry(&server, &username)?
.set_password(&password)
.map_err(|error| error.to_string())
}
#[tauri::command]
fn delete_credential(server: String, username: String) -> Result<(), String> {
match credential_entry(&server, &username)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_credential,
set_credential,
delete_credential
])
.run(tauri::generate_context!())
.expect("error while running Decky");
}

View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
decky_lib::run();
}

View file

@ -0,0 +1,59 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Decky",
"version": "0.2.0",
"identifier": "com.elijahkuntz.decky",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Decky",
"width": 1320,
"height": 820,
"minWidth": 820,
"minHeight": 560,
"center": true,
"decorations": false,
"dragDropEnabled": false
}
],
"security": {
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'"
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"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",
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": {
"installMode": "currentUser",
"installerIcon": "icons/icon.ico"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMyN0NBMzgwMjA3QkM4NwpSV1NIdkFjQ09Nb25ERWJQYStYMDRjSTNORERDcG5nQSthYzdVWnZpdnh2YmlOY1ZlekJVc1luUQo=",
"endpoints": [
"https://git.elijahkuntz.com/Elijah/Decky/raw/branch/main/releases/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}

762
app/src/App.svelte Normal file
View file

@ -0,0 +1,762 @@
<script lang="ts">
import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { check, type Update } from '@tauri-apps/plugin-updater';
import {
Archive,
Check,
ChevronLeft,
ChevronRight,
Columns3,
LayoutList,
LoaderCircle,
Minus,
Pencil,
Plus,
RefreshCw,
Search,
Settings,
Square,
Trash2,
X
} from '@lucide/svelte';
import { cssColor, normalizeColor, textColor } from './lib/color';
import { loadPassword, removePassword, savePassword } from './lib/credentials';
import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
import { loadSettings, saveSettings } from './lib/settings';
import { moveCard as moveCardLocally, moveStack as moveStackLocally } from './lib/reorder';
import type { Board, BoardOrientation, Card, CardDraft, Stack } from './lib/types';
let settings = loadSettings();
let serverUrl = settings.serverUrl;
let username = settings.username;
let appPassword = '';
let rememberPassword = true;
let client: DeckApiClient | null = null;
let boards: Board[] = [];
let stacks: Stack[] = [];
let selectedBoard: Board | null = null;
let busy = false;
let status = 'Connect to Nextcloud to load your boards.';
let search = '';
let settingsOpen = false;
let colorPickerOpen = false;
let dragKind: 'card' | 'stack' | null = null;
let draggedCard: { cardId: number; sourceStackId: number } | null = null;
let draggedStackId: number | null = null;
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 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;
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))),
easing: quintOut
});
let pointerMoveHandler: ((event: PointerEvent) => void) | null = null;
let pointerUpHandler: ((event: PointerEvent) => void) | null = null;
let pointerCancelHandler: (() => void) | null = null;
let keyCancelHandler: ((event: KeyboardEvent) => void) | null = null;
const colorPresets = [
'C84DA5', 'D66A91', 'E89989', 'EBBF72', 'F5D94E',
'B4CF7A', '7EC1AD', '3EA7C3', '0898D0', '3D87D4',
'6875D1', '9B60C5', '000000', 'E6E6E6'
];
$: orientation = (selectedBoard && settings.boardViews[selectedBoard.id]?.orientation) || 'horizontal';
$: visibleStacks = stacks.map((stack) => ({
...stack,
cards: (stack.cards ?? [])
.filter((card) => !card.archived && card.title.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => a.order - b.order)
}));
onMount(async () => {
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (appWindow) {
try { currentVersion = await getVersion(); } catch { /* retain the packaged fallback */ }
}
if (import.meta.env.DEV && new URLSearchParams(location.search).has('demo')) {
selectedBoard = { id: 1, title: 'Homework', color: '8B7CF6' };
boards = [selectedBoard, { id: 2, title: 'Misc. To Do', color: 'E69A88' }, { id: 3, title: 'Test', color: '80C3A5' }];
stacks = [
{ id: 11, boardId: 1, title: 'Saturday', order: 1, cards: [
{ id: 101, stackId: 11, title: 'Preclinical Paperwork', description: 'Review the final checklist before submitting.', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
{ id: 102, stackId: 11, title: '126L Mix Up Assignment', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
]},
{ id: 12, boardId: 1, title: 'Sunday', order: 2, cards: [
{ id: 103, stackId: 12, title: 'Pharm Med Assignment 1', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
{ id: 104, stackId: 12, title: 'Laundry', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null, duedate: '2026-07-20T18:00:00Z' }
]},
{ id: 13, boardId: 1, title: 'Monday', order: 3, cards: [
{ id: 105, stackId: 13, title: 'Psych Discussion Board', description: 'Post the initial response and reply to two classmates.', type: 'plain', owner: 'admin', order: 1, color: 'E69A88', done: null },
{ id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
]}
];
status = 'Homework · 6 cards · Preview data';
return;
}
if (!serverUrl || !username) return;
try {
appPassword = await loadPassword(serverUrl, username);
rememberPassword = Boolean(appPassword);
if (appPassword) await connect(true);
} catch (error) {
status = friendlyError(error);
}
});
function friendlyError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
async function checkForUpdates() {
if (!appWindow) {
updateState = 'error';
updateMessage = 'Update checks are available in the installed desktop application.';
return;
}
updateState = 'checking';
updateMessage = 'Checking Forgejo for a newer signed release…';
updateProgress = 0;
pendingUpdate = null;
try {
pendingUpdate = await check({ timeout: 30_000 });
if (pendingUpdate) {
updateState = 'available';
updateMessage = `Version ${pendingUpdate.version} is ready to install.${pendingUpdate.body ? ` ${pendingUpdate.body}` : ''}`;
} else {
updateState = 'current';
updateMessage = `Decky ${currentVersion} is up to date.`;
}
} catch (error) {
updateState = 'error';
updateMessage = `Update check failed: ${friendlyError(error)}`;
}
}
async function installUpdate() {
if (!pendingUpdate) return;
updateState = 'downloading';
updateMessage = `Downloading Decky ${pendingUpdate.version}…`;
let downloaded = 0;
let total = 0;
try {
await pendingUpdate.downloadAndInstall((event) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? 0;
updateProgress = 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
updateProgress = total > 0 ? Math.min(100, Math.round(downloaded / total * 100)) : 0;
} else if (event.event === 'Finished') {
updateState = 'installing';
updateProgress = 100;
updateMessage = 'Download verified. Installing and restarting Decky…';
}
});
await relaunch();
} catch (error) {
updateState = 'error';
updateMessage = `Update installation failed: ${friendlyError(error)}`;
}
}
async function connect(silent = false) {
busy = true;
status = 'Connecting to Nextcloud…';
try {
client = await DeckApiClient.connect({ serverUrl, username, appPassword });
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
settings = { ...settings, serverUrl, username };
saveSettings(settings);
if (rememberPassword) await savePassword(serverUrl, username, appPassword);
else await removePassword(serverUrl, username);
settingsOpen = false;
const preferred = boards.find((board) => board.id === settings.selectedBoardId) ?? boards[0];
if (preferred) await selectBoard(preferred);
else status = 'Connected, but no active Deck boards were found.';
} catch (error) {
client = null;
if (!silent) settingsOpen = true;
status = friendlyError(error);
} finally {
busy = false;
}
}
async function selectBoard(board: Board) {
if (!client) return;
selectedBoard = board;
settings = { ...settings, selectedBoardId: board.id };
saveSettings(settings);
await refreshBoard();
}
async function refreshBoard() {
if (!client || !selectedBoard) return;
busy = true;
status = `Loading ${selectedBoard.title}…`;
try {
stacks = (await client.getStacks(selectedBoard.id))
.filter((stack) => !stack.deletedAt)
.sort((a, b) => a.order - b.order);
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
status = `${selectedBoard.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
function setOrientation(value: BoardOrientation) {
if (!selectedBoard) return;
settings = {
...settings,
boardViews: { ...settings.boardViews, [selectedBoard.id]: { orientation: value } }
};
saveSettings(settings);
}
function openCreate(stack: Stack) {
colorPickerOpen = false;
editor = {
mode: 'create',
stackId: stack.id,
draft: { title: '', description: '', dueDate: '', color: settings.activeColor }
};
}
async function openEdit(card: Card) {
if (!client || !selectedBoard) return;
busy = true;
try {
const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
colorPickerOpen = false;
editor = {
mode: 'edit',
stackId: card.stackId,
card: fullCard,
draft: {
title: fullCard.title,
description: fullCard.description ?? '',
dueDate: toLocalDateTime(fullCard.duedate),
color: normalizeColor(fullCard.color)
}
};
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
function toLocalDateTime(value?: string | null): string {
if (!value) return '';
const date = new Date(value);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
async function saveCard() {
if (!client || !selectedBoard || !editor || !editor.draft.title.trim()) return;
busy = true;
try {
if (editor.mode === 'create') {
await client.createCard(selectedBoard.id, editor.stackId, { ...editor.draft, title: editor.draft.title.trim() });
} else if (editor.card) {
await client.updateCard(
selectedBoard.id,
editor.stackId,
editor.card.id,
cardUpdateFrom(editor.card, {
title: editor.draft.title.trim(),
description: editor.draft.description.trim() || null,
duedate: editor.draft.dueDate ? new Date(editor.draft.dueDate).toISOString() : null,
color: normalizeColor(editor.draft.color)
})
);
}
editor = null;
colorPickerOpen = false;
await refreshBoard();
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
async function toggleDone(card: Card) {
if (!client || !selectedBoard) return;
busy = true;
try {
const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
const completing = !fullCard.done;
await client.updateCard(
selectedBoard.id,
fullCard.stackId,
fullCard.id,
cardUpdateFrom(fullCard, {
done: completing ? new Date().toISOString() : null,
color: completing ? settings.completedColor : settings.activeColor
})
);
await refreshBoard();
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
async function deleteCard(card: Card) {
if (!client || !selectedBoard || !confirm(`Delete “${card.title}”?`)) return;
busy = true;
try {
await client.deleteCard(selectedBoard.id, card.stackId, card.id);
await refreshBoard();
} catch (error) {
status = friendlyError(error);
} finally {
busy = false;
}
}
function saveAppearance() {
settings = {
...settings,
activeColor: normalizeColor(settings.activeColor),
completedColor: normalizeColor(settings.completedColor)
};
saveSettings(settings);
settingsOpen = false;
}
function disconnect() {
client = null;
boards = [];
stacks = [];
selectedBoard = null;
appPassword = '';
status = 'Disconnected from Nextcloud.';
}
function setSidebarCollapsed(collapsed: boolean) {
settings = { ...settings, sidebarCollapsed: collapsed };
saveSettings(settings);
}
async function runWindowAction(action: 'minimize' | 'maximize' | 'close') {
if (!appWindow) return;
try {
if (action === 'minimize') await appWindow.minimize();
else if (action === 'maximize') await appWindow.toggleMaximize();
else await appWindow.close();
} catch { /* The browser preview has no native window. */ }
}
async function dragWindow(event: MouseEvent) {
if (event.button !== 0 || !appWindow) return;
try { await appWindow.startDragging(); } catch { /* browser preview */ }
}
function cloneStacks(value: Stack[]): Stack[] {
return value.map((stack) => ({ ...stack, cards: stack.cards.map((card) => ({ ...card })) }));
}
function sameOrder(left: Stack[], right: Stack[]): boolean {
return left.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|')
=== right.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|');
}
function startPointerDrag(event: PointerEvent, kind: 'card' | 'stack', title: string, color?: string | null) {
if (event.button !== 0 || search || busy || (event.target as HTMLElement).closest('button')) return false;
event.preventDefault();
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
dragOriginStacks = cloneStacks(stacks);
dragKind = kind;
dragGhost = {
kind, title, color, x: event.clientX, y: event.clientY,
width: kind === 'card' ? rect.width : Math.min(rect.width, 330),
offsetX: Math.min(event.clientX - rect.left, kind === 'card' ? rect.width - 20 : 280),
offsetY: Math.min(event.clientY - rect.top, kind === 'card' ? rect.height - 10 : 24)
};
document.body.classList.add('sorting');
pointerMoveHandler = handlePointerMove;
pointerUpHandler = () => void commitPointerDrag();
pointerCancelHandler = cancelPointerDrag;
keyCancelHandler = (keyEvent) => {
if (keyEvent.key === 'Escape') cancelPointerDrag();
};
window.addEventListener('pointermove', pointerMoveHandler, { passive: false });
window.addEventListener('pointerup', pointerUpHandler, { once: true });
window.addEventListener('pointercancel', pointerCancelHandler, { once: true });
window.addEventListener('blur', pointerCancelHandler, { once: true });
window.addEventListener('keydown', keyCancelHandler);
return true;
}
function beginCardPointer(event: PointerEvent, card: Card) {
if (startPointerDrag(event, 'card', card.title, card.color)) {
draggedCard = { cardId: card.id, sourceStackId: card.stackId };
}
}
function previewCardOrder(stackId: number, index: number) {
if (!draggedCard) return;
const currentStack = stacks.find((stack) => stack.cards.some((card) => card.id === draggedCard?.cardId));
if (!currentStack) return;
const result = moveCardLocally(stacks, draggedCard.cardId, currentStack.id, stackId, index);
if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
}
function beginStackPointer(event: PointerEvent, stack: Stack) {
if (startPointerDrag(event, 'stack', stack.title)) draggedStackId = stack.id;
}
function previewStackOrder(targetStackId: number, after: boolean) {
if (draggedStackId === null) return;
const result = moveStackLocally(stacks, draggedStackId, targetStackId, after);
if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
}
function handlePointerMove(event: PointerEvent) {
if (!dragKind || !dragGhost) return;
event.preventDefault();
dragGhost = { ...dragGhost, x: event.clientX, y: event.clientY };
autoScrollBoard(event.clientX, event.clientY);
const target = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
if (dragKind === 'card' && draggedCard) {
const cardElement = target?.closest<HTMLElement>('[data-card-id]');
const listElement = target?.closest<HTMLElement>('[data-stack-cards]');
if (cardElement) {
const rect = cardElement.getBoundingClientRect();
const stackId = Number(cardElement.dataset.stackId);
const index = Number(cardElement.dataset.cardIndex);
const after = orientation === 'vertical'
? event.clientX > rect.left + rect.width / 2
: event.clientY > rect.top + rect.height / 2;
previewCardOrder(stackId, index + (after ? 1 : 0));
} else if (listElement) {
previewCardOrder(Number(listElement.dataset.stackCards), Number(listElement.dataset.cardCount));
}
} else if (dragKind === 'stack' && draggedStackId !== null) {
const stackElement = target?.closest<HTMLElement>('[data-stack-id]');
if (stackElement) {
const stackId = Number(stackElement.dataset.stackId);
if (stackId === draggedStackId) return;
const rect = stackElement.getBoundingClientRect();
const after = orientation === 'horizontal'
? event.clientX > rect.left + rect.width / 2
: event.clientY > rect.top + rect.height / 2;
previewStackOrder(stackId, after);
}
}
}
function autoScrollBoard(x: number, y: number) {
const canvas = document.querySelector<HTMLElement>('.board-canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const edge = 48;
const speedY = y < rect.top + edge ? -12 : y > rect.bottom - edge ? 12 : 0;
const speedX = x < rect.left + edge ? -12 : x > rect.right - edge ? 12 : 0;
if (speedX || speedY) canvas.scrollBy({ left: speedX, top: speedY });
}
async function commitPointerDrag() {
const origin = dragOriginStacks;
const card = draggedCard;
const stackId = draggedStackId;
const board = selectedBoard;
cleanupPointerDrag();
if (!origin || !board || sameOrder(origin, stacks) || !client) return;
busy = true;
try {
if (card) {
const targetStack = stacks.find((stack) => stack.cards.some((item) => item.id === card.cardId));
const targetIndex = targetStack?.cards.findIndex((item) => item.id === card.cardId) ?? -1;
if (!targetStack || targetIndex < 0) throw new Error('The card drop position could not be resolved.');
await client.reorderCard(board.id, card.sourceStackId, card.cardId, targetStack.id, targetIndex);
await refreshBoard();
} else if (stackId !== null) {
const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
stacks = await client.reorderStack(board.id, stackId, targetIndex);
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((item) => !item.archived).length ?? 0), 0);
status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Stack order saved`;
}
} catch (error) {
stacks = origin;
status = friendlyError(error);
await refreshBoard();
} finally { busy = false; }
}
function cancelPointerDrag() {
if (dragOriginStacks) stacks = dragOriginStacks;
cleanupPointerDrag();
}
function cleanupPointerDrag() {
if (pointerMoveHandler) window.removeEventListener('pointermove', pointerMoveHandler);
if (pointerUpHandler) window.removeEventListener('pointerup', pointerUpHandler);
if (pointerCancelHandler) {
window.removeEventListener('pointercancel', pointerCancelHandler);
window.removeEventListener('blur', pointerCancelHandler);
}
if (keyCancelHandler) window.removeEventListener('keydown', keyCancelHandler);
document.body.classList.remove('sorting');
pointerMoveHandler = null;
pointerUpHandler = null;
pointerCancelHandler = null;
keyCancelHandler = null;
dragKind = null;
draggedCard = null;
draggedStackId = null;
dragOriginStacks = null;
dragGhost = null;
}
</script>
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} · Decky` : 'Decky'}</title></svelte:head>
<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
<header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
<div class="window-title"><span class="window-mark"><Columns3 size={14} /></span><span>Decky</span></div>
<div class="window-controls" role="group" aria-label="Window controls">
<button aria-label="Minimize" title="Minimize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('minimize')}><Minus size={15} /></button>
<button aria-label="Maximize" title="Maximize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('maximize')}><Square size={12} /></button>
<button class="window-close" aria-label="Close" title="Close" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('close')}><X size={15} /></button>
</div>
</header>
<aside class="sidebar">
<div class="sidebar-content">
{#if false}
<form class="connection-form" on:submit|preventDefault={() => connect()}>
<label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
<label>Username<input bind:value={username} autocomplete="username" /></label>
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : 'Connect'}</button>
</form>
{/if}
<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>
{/each}
</nav>
</div>
<div class="sidebar-tools">
<div class="sidebar-layout" aria-label="Board orientation">
<button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
<button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
</div>
<button class="sidebar-tool" title="Refresh" disabled={!selectedBoard || busy} on:click={refreshBoard}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
<button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
</div>
</aside>
<main class="workspace">
{#if selectedBoard}
<div class="workspace-tools">
<div class="board-title">
<button class="sidebar-toggle" title={settings.sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'} on:click={() => setSidebarCollapsed(!settings.sidebarCollapsed)}>
{#if settings.sidebarCollapsed}<ChevronRight size={18} />{:else}<ChevronLeft size={18} />{/if}
</button>
{#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>
</div>
<div class:vertical={orientation === 'vertical'} class="board-canvas">
{#each visibleStacks as stack (stack.id)}
<section
class:dragging={draggedStackId === stack.id}
class="stack"
role="group"
aria-label={`${stack.title} stack`}
data-stack-id={stack.id}
animate:flip={{ duration: reduceMotion ? 0 : 180 }}
>
<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>
</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)}
<article
class:completed={Boolean(card.done)}
class:dragging={draggedCard?.cardId === card.id}
class="deck-card"
role="listitem"
data-card-id={card.id}
data-stack-id={stack.id}
data-card-index={cardIndex}
style:--card-color={cssColor(card.color)}
style:--card-text={textColor(card.color)}
on:pointerdown={(event) => beginCardPointer(event, card)}
animate:flip={{ duration: reduceMotion ? 0 : 170 }}
in:receiveCard={{ key: card.id }}
out:sendCard={{ key: card.id }}
>
<button class="complete-button" class:checked={Boolean(card.done)} title={card.done ? 'Mark active' : 'Mark complete'} on:click={() => toggleDone(card)}>
{#if card.done}<Check size={15} strokeWidth={3} />{/if}
</button>
<div class="card-body">
<h3>{card.title}</h3>
{#if card.description}<p>{card.description}</p>{/if}
{#if card.duedate}<span class="due">Due {new Date(card.duedate).toLocaleDateString([], { month: 'short', day: 'numeric' })}</span>{/if}
</div>
<div class="card-actions">
<button title="Edit card" on:click={() => openEdit(card)}><Pencil size={14} /></button>
<button title="Delete card" on:click={() => deleteCard(card)}><Trash2 size={14} /></button>
</div>
</article>
{/each}
{#if stack.cards.length === 0}<button class="empty-stack" on:click={() => openCreate(stack)}><Plus size={16} /> Add a card</button>{/if}
</div>
</section>
{/each}
</div>
{:else}
<div class="empty-state">
<div class="empty-illustration"><Archive size={34} /><span></span><span></span></div>
<h1>Your boards, without the browser clutter.</h1>
<p>Connect Decky to Nextcloud, then choose a board from the sidebar.</p>
<button class="primary-button" on:click={() => (settingsOpen = true)}>Set up connection</button>
</div>
{/if}
</main>
<footer class="statusbar">
{#if busy}<LoaderCircle size={14} class="spinning" />{:else}<span class:connected={Boolean(client)} class="status-dot"></span>{/if}
<span>{status}</span>
</footer>
</div>
{#if dragGhost}
<div
class:card-drag-ghost={dragGhost.kind === 'card'}
class:stack-drag-ghost={dragGhost.kind === 'stack'}
class="drag-ghost"
style:left={`${dragGhost.x - dragGhost.offsetX}px`}
style:top={`${dragGhost.y - dragGhost.offsetY}px`}
style:width={`${dragGhost.width}px`}
style:--ghost-color={cssColor(dragGhost.color)}
>
{#if dragGhost.kind === 'card'}<span class="ghost-check"></span>{/if}
<strong>{dragGhost.title}</strong>
</div>
{/if}
{#if editor}
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (editor = null, colorPickerOpen = false)}>
<form class="modal" on:submit|preventDefault={saveCard}>
<header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
<label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details…"></textarea></label>
<div class="form-row">
<label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
<label>Card 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(editor.draft.color)}></span>
<span>#{normalizeColor(editor.draft.color)}</span>
</button>
{#if colorPickerOpen}
<span class="color-popover">
<span class="swatch-grid">
{#each colorPresets as color}
<button type="button" class:active={normalizeColor(editor.draft.color) === color} class="color-swatch" style:background={cssColor(color)} title={`#${color}`} on:click={() => { if (editor) editor.draft.color = color; colorPickerOpen = false; }}>
{#if normalizeColor(editor.draft.color) === color}<Check size={16} strokeWidth={3} />{/if}
</button>
{/each}
</span>
<span class="custom-color"><span>Custom color</span><input type="color" value={cssColor(editor.draft.color)} on:input={(event) => editor && (editor.draft.color = (event.currentTarget as HTMLInputElement).value.slice(1))} /></span>
<input class="color-hex" aria-label="Hex color" bind:value={editor.draft.color} maxlength="7" />
</span>
{/if}
</span>
</label>
</div>
<footer><button type="button" class="secondary-button" on:click={() => (editor = null, colorPickerOpen = false)}>Cancel</button><button class="primary-button" disabled={busy || !editor.draft.title.trim()}>{editor.mode === 'create' ? 'Create card' : 'Save changes'}</button></footer>
</form>
</div>
{/if}
{#if settingsOpen}
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (settingsOpen = false)}>
<div class="modal settings-modal">
<header><div><small>SETTINGS</small><h2>Decky settings</h2></div><button type="button" class="icon-button" on:click={() => (settingsOpen = false)}><X size={20} /></button></header>
<section class="settings-section">
<div class="settings-heading"><div><h3>Nextcloud connection</h3><p>Use a revocable Nextcloud app password.</p></div><span class:connected={Boolean(client)} class="connection-badge"><span class="status-dot"></span>{client ? `Connected as ${username}` : 'Not connected'}</span></div>
<form class="settings-connection" on:submit|preventDefault={() => connect()}>
<label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
<label>Username<input bind:value={username} autocomplete="username" /></label>
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
<div class="settings-actions">
{#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : client ? 'Reconnect' : 'Connect'}</button>
</div>
</form>
</section>
<section class="settings-section update-section">
<div class="settings-heading">
<div><h3>Decky updates</h3><p>Installer updates are signed and delivered through your Forgejo instance.</p></div>
<span class="version-badge">Version {currentVersion}</span>
</div>
<div class:error={updateState === 'error'} class="update-status" aria-live="polite">
<div>
<strong>{updateState === 'available' ? 'Update available' : updateState === 'current' ? 'Up to date' : updateState === 'error' ? 'Update unavailable' : 'Release channel: stable'}</strong>
<p>{updateMessage}</p>
</div>
{#if updateState === 'downloading' || updateState === 'installing'}
<span class="update-progress" aria-label={`Update progress ${updateProgress}%`}><span style:width={`${updateProgress}%`}></span></span>
{/if}
</div>
<div class="settings-actions update-actions">
{#if updateState === 'available'}
<button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
{:else}
<button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
{updateState === 'checking' ? 'Checking…' : 'Check for updates'}
</button>
{/if}
</div>
</section>
<form class="settings-section" on:submit|preventDefault={saveAppearance}>
<div class="settings-heading"><div><h3>Card colors</h3><p>Completing a card applies the completed color; unchecking restores the active color.</p></div></div>
<div class="color-settings">
<label>Active cards<span class="color-field"><input type="color" value={cssColor(settings.activeColor)} on:input={(event) => (settings.activeColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.activeColor} /></span></label>
<label>Completed cards<span class="color-field"><input type="color" value={cssColor(settings.completedColor)} on:input={(event) => (settings.completedColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.completedColor} /></span></label>
</div>
<footer><button type="button" class="secondary-button" on:click={() => (settingsOpen = false)}>Cancel</button><button class="primary-button">Save settings</button></footer>
</form>
</div>
</div>
{/if}

234
app/src/app.css Normal file
View file

@ -0,0 +1,234 @@
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f2f2f2;
background: #181818;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #181818;
--panel: #202020;
--panel-2: #292929;
--panel-3: #333333;
--line: rgba(255, 255, 255, .1);
--muted: #a8a8a8;
--accent: #0082c9;
--accent-strong: #4db2e8;
--success: #46ba61;
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
button, input, textarea { font: inherit; }
button { color: inherit; }
* { scrollbar-width: thin; scrollbar-color: #626262 #202020; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: #202020; }
*::-webkit-scrollbar-thumb { min-height: 34px; border: 2px solid #202020; border-radius: 8px; background: #626262; }
*::-webkit-scrollbar-thumb:hover { background: #777; }
*::-webkit-scrollbar-corner { background: #202020; }
.app-shell {
height: 100%;
display: grid;
grid-template: 34px minmax(0, 1fr) 28px / 250px minmax(0, 1fr);
background: var(--bg);
}
.window-titlebar { grid-column: 1 / -1; grid-row: 1; display: flex; align-items: center; justify-content: space-between; min-width: 0; border-bottom: 1px solid var(--line); background: #171717; user-select: none; }
.window-title { display: flex; align-items: center; gap: 8px; padding-left: 10px; font-size: 12px; font-weight: 600; }
.window-mark { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: 5px; color: #fff; background: var(--accent); }
.window-controls { align-self: stretch; display: flex; }
.window-controls button { width: 46px; height: 100%; display: flex; align-items: center; justify-content: center; padding: 0; border: 0; color: #d2d2d2; background: transparent; cursor: default; }
.window-controls button:hover { color: #fff; background: rgba(255,255,255,.1); }
.window-controls .window-close:hover { background: #c42b1c; }
.icon-button, .card-actions button {
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
border-radius: 6px;
}
.icon-button { width: 34px; height: 34px; color: var(--muted); }
.icon-button:hover:not(:disabled), .card-actions button:hover { color: #fff; background: rgba(255,255,255,.08); }
.icon-button:disabled { opacity: .35; cursor: default; }
.sidebar {
grid-row: 2;
min-width: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
border-right: 1px solid var(--line);
background: var(--panel);
}
.sidebar-content { min-height: 0; overflow-y: auto; padding: 5px 12px 18px; }
.connection-summary { width: 100%; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; text-align: left; border: 0; border-radius: 6px; background: transparent; padding: 9px 8px; cursor: pointer; }
.connection-summary:hover { background: rgba(255,255,255,.055); }
.connection-summary span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.connection-summary strong { font-size: 13px; }
.connection-summary small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #717171; box-shadow: 0 0 0 3px rgba(113,113,113,.12); flex: 0 0 auto; }
.status-dot.connected { background: var(--success); box-shadow: 0 0 0 3px rgba(70,186,97,.13); }
.connection-form { margin: 4px 0 18px; padding: 12px; display: grid; gap: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; }
label { display: grid; gap: 7px; font-size: 12px; font-weight: 650; color: #c8c8c8; }
input, textarea { width: 100%; border: 1px solid var(--line); outline: 0; border-radius: 6px; padding: 9px 10px; color: #f5f5f5; background: #171717; transition: border-color .15s, box-shadow .15s; }
input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,130,201,.22); }
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 { 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); }
.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-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); }
.sidebar-tool, .sidebar-layout button { min-height: 34px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: 6px; color: #c4c4c4; background: transparent; cursor: pointer; text-align: left; }
.sidebar-tool:hover:not(:disabled), .sidebar-layout button:hover { color: #fff; background: rgba(255,255,255,.07); }
.sidebar-tool:disabled { opacity: .35; cursor: default; }
.sidebar-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; padding-bottom: 4px; }
.sidebar-layout button { justify-content: center; gap: 6px; padding: 0 6px; background: #292929; }
.sidebar-layout button.active { color: #fff; background: #3a3a3a; box-shadow: inset 0 -2px var(--accent); }
.sidebar-tool span, .sidebar-layout span { font-size: 12px; }
.workspace { grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #232323; }
.workspace-tools { min-height: 46px; flex: 0 0 auto; display: grid; grid-template-columns: minmax(120px, 1fr) minmax(180px, 280px) minmax(80px, 1fr); align-items: center; gap: 16px; padding: 6px 18px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--line); }
.workspace-tools > span:last-child { justify-self: end; }
.board-title { min-width: 0; display: flex; align-items: center; gap: 9px; color: #f2f2f2; font-size: 15px; }
.board-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-toggle { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 5px; color: var(--muted); background: transparent; cursor: pointer; }
.sidebar-toggle:hover { color: #fff; background: rgba(255,255,255,.08); }
.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; }
.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; }
.stack { width: 300px; flex: 0 0 300px; max-height: 100%; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #2a2a2a; }
.stack.dragging, .deck-card.dragging { opacity: .32; }
.vertical .stack { width: min(900px, 100%); flex: 0 0 auto; max-height: none; align-self: center; overflow: visible; }
.stack-header { flex: 0 0 auto; min-height: 43px; display: flex; align-items: center; justify-content: space-between; padding: 5px 7px 5px 12px; border-bottom: 1px solid rgba(255,255,255,.07); }
.stack-header { cursor: grab; touch-action: none; }
.sorting .stack-header { cursor: grabbing; }
.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; }
.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; }
.deck-card { --card-color: #d9dee8; --card-text: #111; position: relative; min-height: 58px; display: grid; grid-template-columns: 23px minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px; overflow: hidden; color: var(--card-text); border-radius: 7px; background: var(--card-color); box-shadow: inset 0 1px rgba(255,255,255,.18); transition: filter .15s, opacity .15s; }
.deck-card { cursor: grab; touch-action: none; }
.sorting .deck-card { cursor: grabbing; }
.card-list.card-drag-target:empty { outline: 2px dashed var(--accent); outline-offset: -7px; }
.deck-card:hover { filter: brightness(1.035); }
.deck-card.completed { opacity: .78; }
.deck-card.completed h3 { text-decoration: line-through; text-decoration-thickness: 1.5px; }
.complete-button { position: relative; z-index: 1; width: 22px; height: 22px; min-width: 22px; display: flex; align-items: center; justify-content: center; align-self: start; margin: 0; padding: 0; line-height: 0; border: 2px solid currentColor; border-radius: 6px; color: inherit; background: rgba(255,255,255,.15); opacity: .62; cursor: pointer; }
.complete-button svg { display: block; flex: 0 0 auto; }
.complete-button:hover, .complete-button.checked { opacity: 1; }
.complete-button.checked { color: #fff; border-color: rgba(255,255,255,.92); background: rgba(20,20,20,.58); }
.card-body { position: relative; z-index: 1; min-width: 0; padding-right: 2px; }
.card-body h3 { margin: 1px 0 4px; font-size: 13px; line-height: 1.35; font-weight: 650; }
.card-body p { display: -webkit-box; margin: 0 0 6px; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: 11px; line-height: 1.35; opacity: .75; }
.due { display: inline-block; padding: 2px 6px; border-radius: 8px; font-size: 10px; font-weight: 650; background: rgba(20,20,20,.13); }
.card-actions { position: absolute; z-index: 2; right: 6px; bottom: 5px; display: flex; gap: 2px; opacity: 0; transition: opacity .15s; }
.deck-card:hover .card-actions { opacity: 1; }
.card-actions button { width: 26px; height: 26px; color: inherit; background: rgba(255,255,255,.16); }
.empty-stack { min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 1px dashed rgba(255,255,255,.14); border-radius: 6px; color: var(--muted); background: transparent; cursor: pointer; }
.empty-stack:hover { color: #fff; border-color: rgba(255,255,255,.26); background: rgba(255,255,255,.025); }
.empty-state { margin: auto; max-width: 500px; padding: 40px; text-align: center; }
.empty-state h1 { margin: 22px 0 10px; font-size: clamp(24px, 4vw, 34px); letter-spacing: -.035em; }
.empty-state p { margin: 0 auto 24px; color: var(--muted); line-height: 1.55; }
.empty-illustration { width: 104px; height: 84px; position: relative; display: grid; place-items: center; margin: auto; border: 1px solid var(--line); border-radius: 12px; color: var(--accent-strong); background: var(--panel-2); }
.empty-illustration span { position: absolute; width: 66px; height: 44px; z-index: -1; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); transform: translate(-28px, 13px) rotate(-12deg); }
.empty-illustration span:last-child { transform: translate(28px, 13px) rotate(12deg); }
.statusbar { grid-column: 1 / -1; grid-row: 3; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 0 12px; border-top: 1px solid var(--line); color: var(--muted); background: #161616; font-size: 11px; }
.statusbar span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.spinning { animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.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; }
.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; }
.settings-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.4; }
.connection-badge { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; padding: 6px 9px; border-radius: 14px; color: var(--muted); background: #1c1c1c; font-size: 11px; }
.connection-badge.connected { color: #d9f6e0; background: rgba(70,186,97,.13); }
.connection-badge.connected .status-dot { background: var(--success); }
.settings-connection { display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
.settings-connection label:nth-child(3) { grid-column: 1 / -1; }
.settings-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: 8px; }
.version-badge { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--line); border-radius: 14px; color: var(--muted); background: #1c1c1c; font-size: 11px; }
.update-status { display: grid; gap: 10px; padding: 12px 14px; border: 1px solid rgba(0,130,201,.25); border-radius: 7px; background: rgba(0,130,201,.07); }
.update-status.error { border-color: rgba(225,95,95,.32); background: rgba(225,95,95,.07); }
.update-status strong { font-size: 13px; }
.update-status p { margin: 3px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.update-progress { height: 4px; overflow: hidden; border-radius: 99px; background: rgba(255,255,255,.1); }
.update-progress span { display: block; height: 100%; border-radius: inherit; background: var(--accent-strong); transition: width .18s ease-out; }
.update-actions { grid-column: auto; }
.modal header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.modal header small { color: var(--accent-strong); font-weight: 750; letter-spacing: .09em; }
.modal h2 { margin: 4px 0 0; font-size: 21px; letter-spacing: -.02em; }
.modal footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 3px; }
.form-row, .color-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.color-field { display: grid; grid-template-columns: 42px 1fr; gap: 8px; }
.color-field input[type="color"] { height: 38px; padding: 4px; cursor: pointer; }
.modal-copy { margin: -5px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.color-control { position: relative; display: block; }
.color-trigger { width: 100%; height: 38px; display: flex; align-items: center; gap: 10px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 6px; color: #eee; background: #171717; cursor: pointer; }
.color-trigger:hover { border-color: rgba(255,255,255,.25); }
.color-preview { width: 25px; height: 25px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.3); }
.color-popover { position: absolute; z-index: 30; right: 0; bottom: calc(100% + 7px); width: 236px; display: grid; gap: 11px; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: #242424; box-shadow: 0 15px 40px rgba(0,0,0,.5); }
.swatch-grid { display: grid; grid-template-columns: repeat(5, 32px); justify-content: space-between; gap: 8px; }
.color-swatch { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; padding: 0; border: 2px solid transparent; border-radius: 50%; color: #151515; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(255,255,255,.15); }
.color-swatch:hover { transform: scale(1.08); }
.color-swatch.active { border-color: #fff; box-shadow: 0 0 0 2px #111; }
.custom-color { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
.custom-color input[type="color"] { width: 42px; height: 30px; padding: 2px; cursor: pointer; }
.color-hex { height: 33px; padding-block: 6px; text-transform: uppercase; }
body.sorting { cursor: grabbing !important; user-select: none; }
body.sorting * { cursor: grabbing !important; }
.drag-ghost { --ghost-color: #3a3a3a; position: fixed; z-index: 100; min-height: 44px; display: flex; align-items: center; gap: 9px; padding: 10px 12px; pointer-events: none; border: 1px solid rgba(255,255,255,.24); border-radius: 8px; color: #f7f7f7; background: #333; box-shadow: 0 18px 42px rgba(0,0,0,.48); transform: rotate(1.2deg) scale(1.015); opacity: .96; }
.drag-ghost.card-drag-ghost { color: var(--card-text, #111); background: var(--ghost-color); }
.drag-ghost.stack-drag-ghost { min-height: 46px; border-color: rgba(0,130,201,.6); background: #292929; }
.drag-ghost strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.ghost-check { width: 20px; height: 20px; flex: 0 0 auto; border: 2px solid currentColor; border-radius: 6px; opacity: .58; }
.sidebar-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.sidebar-collapsed .sidebar { display: none; }
@media (max-width: 700px) {
.app-shell, .sidebar-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.sidebar { display: none; }
.workspace-tools { grid-template-columns: 1fr auto; gap: 10px; padding-inline: 12px; }
.workspace-tools .search-box { grid-column: 1 / -1; grid-row: 2; }
.workspace-tools { padding-block: 7px; }
.board-canvas { padding: 10px 12px 18px; }
.form-row, .color-settings { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
.drag-ghost { transform: none; }
}

17
app/src/lib/color.ts Normal file
View file

@ -0,0 +1,17 @@
export function normalizeColor(value?: string | null): string {
const normalized = (value ?? '').trim().replace(/^#/, '').toUpperCase();
return /^[0-9A-F]{6}$/.test(normalized) ? normalized : 'D9DEE8';
}
export function textColor(background?: string | null): string {
const color = normalizeColor(background);
const red = Number.parseInt(color.slice(0, 2), 16);
const green = Number.parseInt(color.slice(2, 4), 16);
const blue = Number.parseInt(color.slice(4, 6), 16);
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
return luminance > 0.58 ? '#11151d' : '#ffffff';
}
export function cssColor(value?: string | null): string {
return `#${normalizeColor(value)}`;
}

View file

@ -0,0 +1,16 @@
import { invoke } from '@tauri-apps/api/core';
export async function loadPassword(server: string, username: string): Promise<string> {
if (!server || !username || !('__TAURI_INTERNALS__' in window)) return '';
return (await invoke<string | null>('get_credential', { server, username })) ?? '';
}
export async function savePassword(server: string, username: string, password: string): Promise<void> {
if (!('__TAURI_INTERNALS__' in window)) return;
await invoke('set_credential', { server, username, password });
}
export async function removePassword(server: string, username: string): Promise<void> {
if (!('__TAURI_INTERNALS__' in window)) return;
await invoke('delete_credential', { server, username });
}

View file

@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { cardUpdateFrom, DeckApiClient, type FetchLike } from './deck-api';
import type { Card } from './types';
const options = { serverUrl: 'https://cloud.example.test', username: 'admin', appPassword: 'secret' };
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
describe('DeckApiClient', () => {
it('sends required authentication headers', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async () => json([]));
const client = await DeckApiClient.connect(options, fetchImpl);
await client.getBoards();
const headers = new Headers(fetchImpl.mock.calls[1][1]?.headers);
expect(headers.get('OCS-APIRequest')).toBe('true');
expect(headers.get('Authorization')).toMatch(/^Basic /);
});
it('falls back to the rewrite-friendly API path', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) =>
String(input).includes('/index.php/') ? json({ message: 'not found' }, 404) : json([])
);
const client = await DeckApiClient.connect(options, fetchImpl);
expect(client.apiBaseUrl.toString()).toBe('https://cloud.example.test/apps/deck/api/v1.0/');
});
it('preserves complete card fields and normalizes object owners', () => {
const card: Card = {
id: 1,
stackId: 2,
title: 'Task',
description: 'Details',
type: 'plain',
owner: { uid: 'admin' },
order: 3,
done: null,
color: '#aabbcc'
};
expect(cardUpdateFrom(card, { done: '2026-01-01T00:00:00Z' })).toMatchObject({
owner: 'admin',
description: 'Details',
order: 3,
done: '2026-01-01T00:00:00Z',
color: 'AABBCC'
});
});
it('can explicitly clear description, due date, and completion', () => {
const card: Card = {
id: 1, stackId: 2, title: 'Task', description: 'Old', type: 'plain', owner: 'admin', order: 0,
duedate: '2026-01-01T00:00:00Z', done: '2026-01-01T00:00:00Z', color: 'ABCDEF'
};
expect(cardUpdateFrom(card, { description: null, duedate: null, done: null })).toMatchObject({
description: null, duedate: null, done: null, color: 'ABCDEF'
});
});
it('retries cross-stack movement through the destination route when Deck ignores the body stack', async () => {
let puts = 0;
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith('/boards')) return json([]);
if (init?.method === 'PUT') {
puts += 1;
return json(true);
}
if (url.includes('/stacks/8/')) {
return puts > 1
? json({ id: 11, stackId: 8, title: 'Moved', owner: 'admin' })
: json({ status: 404 }, 404);
}
return json({ id: 11, stackId: 7, title: 'Not moved', owner: 'admin' });
});
const client = await DeckApiClient.connect(options, fetchImpl);
const moved = await client.reorderCard(2, 7, 11, 8, 0);
expect(moved.stackId).toBe(8);
expect(puts).toBe(2);
});
it('reorders a stack through the same OCS endpoint as the Nextcloud web client and verifies persistence', async () => {
const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith('/boards')) return json([]);
if (url.includes('/ocs/v2.php/')) return json({ ocs: { meta: { status: 'ok' }, data: [] } });
return json([
{ id: 8, boardId: 2, title: 'Earlier', order: 0, cards: [] },
{ id: 7, boardId: 2, title: 'Today', order: 1, cards: [] }
]);
});
const client = await DeckApiClient.connect(options, fetchImpl);
await client.reorderStack(2, 7, 1);
const [url, init] = fetchImpl.mock.calls[1];
expect(String(url)).toBe('https://cloud.example.test/ocs/v2.php/apps/deck/api/v1.0/stacks/7/reorder?format=json');
expect(init?.method).toBe('PUT');
expect(JSON.parse(String(init?.body))).toEqual({ order: 1, boardId: 2 });
expect(String(fetchImpl.mock.calls[2][0])).toContain('/boards/2/stacks');
});
});

212
app/src/lib/deck-api.ts Normal file
View file

@ -0,0 +1,212 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { normalizeColor } from './color';
import type { Board, Card, CardDraft, DeckUser, Stack } from './types';
export const DOCUMENTED_API_PATH = 'index.php/apps/deck/api/v1.0/';
export const REWRITTEN_API_PATH = 'apps/deck/api/v1.0/';
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export class DeckApiError extends Error {
constructor(
public readonly status: number,
public readonly url: string,
public readonly responseBody: string
) {
super(`Deck request failed with ${status}${responseBody ? `: ${responseBody}` : ''}`);
}
}
export interface ConnectionOptions {
serverUrl: string;
username: string;
appPassword: string;
apiPath?: string;
}
export interface CardUpdate {
title: string;
type: string;
owner: string;
description: string | null;
order: number;
duedate: string | null;
startdate: string | null;
archived: boolean;
done: string | null;
color: string;
}
function encodeBasicAuth(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function normalizeServerUrl(value: string): URL {
const url = new URL(value.trim());
const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) {
throw new Error('Decky requires HTTPS except when connecting to localhost.');
}
url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
url.search = '';
url.hash = '';
return url;
}
function ownerId(owner: string | DeckUser): string {
if (typeof owner === 'string') return owner;
return owner.uid || owner.primaryKey || '';
}
export function cardUpdateFrom(card: Card, changes: Partial<CardUpdate> = {}): CardUpdate {
return {
title: changes.title ?? card.title,
type: changes.type ?? card.type ?? 'plain',
owner: changes.owner ?? ownerId(card.owner),
description: changes.description !== undefined ? changes.description : card.description ?? null,
order: changes.order ?? card.order,
duedate: changes.duedate !== undefined ? changes.duedate : card.duedate ?? null,
startdate: changes.startdate !== undefined ? changes.startdate : card.startdate ?? null,
archived: changes.archived ?? card.archived ?? false,
done: changes.done !== undefined ? changes.done : card.done ?? null,
color: normalizeColor(changes.color ?? card.color)
};
}
export class DeckApiClient {
private constructor(
private readonly options: ConnectionOptions,
public readonly apiBaseUrl: URL,
private readonly fetchImpl: FetchLike
) {}
static async connect(options: ConnectionOptions, fetchImpl: FetchLike = tauriFetch): Promise<DeckApiClient> {
if (!options.username.trim() || !options.appPassword) throw new Error('Username and app password are required.');
const server = normalizeServerUrl(options.serverUrl);
const paths = [...new Set([options.apiPath, DOCUMENTED_API_PATH, REWRITTEN_API_PATH].filter(Boolean))] as string[];
let lastError: unknown;
for (const path of paths) {
const client = new DeckApiClient(options, new URL(path.replace(/^\/+/, ''), server), fetchImpl);
try {
await client.getBoards();
return client;
} catch (error) {
lastError = error;
if (!(error instanceof DeckApiError) || ![404, 405].includes(error.status)) throw error;
}
}
throw lastError ?? new Error('The Nextcloud Deck API could not be reached.');
}
getBoards(): Promise<Board[]> {
return this.request<Board[]>('boards');
}
getStacks(boardId: number): Promise<Stack[]> {
return this.request<Stack[]>(`boards/${boardId}/stacks`);
}
updateStack(boardId: number, stackId: number, title: string, order: number): Promise<Stack> {
return this.request<Stack>(`boards/${boardId}/stacks/${stackId}`, {
method: 'PUT',
body: JSON.stringify({ title, order })
});
}
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);
url.searchParams.set('format', 'json');
await this.requestUrl<unknown>(url, {
method: 'PUT',
body: JSON.stringify({ order, boardId })
});
const stacks = (await this.getStacks(boardId))
.filter((stack) => !stack.deletedAt)
.sort((a, b) => a.order - b.order);
const actualIndex = stacks.findIndex((stack) => stack.id === stackId);
if (actualIndex !== order) {
throw new Error(`Nextcloud did not persist the stack reorder (expected position ${order + 1}, received ${actualIndex + 1}).`);
}
return stacks;
}
getCard(boardId: number, stackId: number, cardId: number): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`);
}
createCard(boardId: number, stackId: number, draft: CardDraft): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards`, {
method: 'POST',
body: JSON.stringify({
title: draft.title,
type: 'plain',
order: 999,
description: draft.description || null,
duedate: draft.dueDate ? new Date(draft.dueDate).toISOString() : null,
color: normalizeColor(draft.color)
})
});
}
updateCard(boardId: number, stackId: number, cardId: number, update: CardUpdate): Promise<Card> {
return this.request<Card>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, {
method: 'PUT',
body: JSON.stringify(update)
});
}
deleteCard(boardId: number, stackId: number, cardId: number): Promise<void> {
return this.request<void>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`, { method: 'DELETE' });
}
async reorderCard(boardId: number, sourceStackId: number, cardId: number, stackId: number, order: number): Promise<Card> {
const body = JSON.stringify({ stackId, order });
await this.request<unknown>(`boards/${boardId}/stacks/${sourceStackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
let card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
if (sourceStackId !== stackId && card.stackId === sourceStackId) {
await this.request<unknown>(`boards/${boardId}/stacks/${stackId}/cards/${cardId}/reorder`, { method: 'PUT', body });
card = await this.resolveMovedCard(boardId, sourceStackId, stackId, cardId);
}
return card;
}
private async resolveMovedCard(boardId: number, sourceStackId: number, targetStackId: number, cardId: number): Promise<Card> {
try {
return await this.getCard(boardId, targetStackId, cardId);
} catch (error) {
if (error instanceof DeckApiError && error.status === 404 && sourceStackId !== targetStackId) {
return this.getCard(boardId, sourceStackId, cardId);
}
throw error;
}
}
private async request<T>(relativePath: string, init: RequestInit = {}): Promise<T> {
const url = new URL(relativePath, this.apiBaseUrl).toString();
return this.requestUrl<T>(url, init);
}
private async requestUrl<T>(url: string | URL, init: RequestInit = {}): Promise<T> {
const urlString = url.toString();
const response = await this.fetchImpl(urlString, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'OCS-APIRequest': 'true',
Authorization: `Basic ${encodeBasicAuth(this.options.username, this.options.appPassword)}`,
...init.headers
}
});
if (!response.ok) throw new DeckApiError(response.status, urlString, await response.text());
if (response.status === 204) return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { moveCard, moveStack } from './reorder';
import type { Stack } from './types';
const stacks: Stack[] = [
{ id: 1, boardId: 9, title: 'One', order: 0, cards: [
{ id: 10, stackId: 1, title: 'A', type: 'plain', owner: 'admin', order: 0 },
{ id: 11, stackId: 1, title: 'B', type: 'plain', owner: 'admin', order: 1 }
] },
{ id: 2, boardId: 9, title: 'Two', order: 1, cards: [
{ id: 20, stackId: 2, title: 'C', type: 'plain', owner: 'admin', order: 0 }
] },
{ id: 3, boardId: 9, title: 'Three', order: 2, cards: [] }
];
describe('reorder helpers', () => {
it('moves a card between stacks and assigns contiguous order values', () => {
const result = moveCard(stacks, 10, 1, 2, 1)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks[0].cards.map((card) => [card.title, card.order])).toEqual([['B', 0]]);
expect(result.stacks[1].cards.map((card) => [card.title, card.stackId, card.order])).toEqual([
['C', 2, 0], ['A', 2, 1]
]);
});
it('accounts for the removed item when reordering inside one stack', () => {
const result = moveCard(stacks, 10, 1, 1, 2)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks[0].cards.map((card) => card.title)).toEqual(['B', 'A']);
});
it('moves stacks before or after the selected target', () => {
const result = moveStack(stacks, 1, 2, true)!;
expect(result.targetIndex).toBe(1);
expect(result.stacks.map((stack) => [stack.title, stack.order])).toEqual([
['Two', 0], ['One', 1], ['Three', 2]
]);
});
it('supports consecutive card previews while a drag crosses multiple positions', () => {
const firstPreview = moveCard(stacks, 10, 1, 2, 1)!;
const secondPreview = moveCard(firstPreview.stacks, 10, 2, 2, 0)!;
expect(secondPreview.stacks[1].cards.map((card) => [card.title, card.order])).toEqual([
['A', 0], ['C', 1]
]);
});
it('supports consecutive stack previews while a drag crosses multiple stacks', () => {
const firstPreview = moveStack(stacks, 1, 2, true)!;
const secondPreview = moveStack(firstPreview.stacks, 1, 3, true)!;
expect(secondPreview.stacks.map((stack) => [stack.title, stack.order])).toEqual([
['Two', 0], ['Three', 1], ['One', 2]
]);
});
});

44
app/src/lib/reorder.ts Normal file
View file

@ -0,0 +1,44 @@
import type { Stack } from './types';
export function moveCard(
stacks: Stack[],
cardId: number,
sourceStackId: number,
targetStackId: number,
requestedIndex: number
): { stacks: Stack[]; targetIndex: number } | null {
const source = stacks.find((stack) => stack.id === sourceStackId);
const sourceIndex = source?.cards.findIndex((card) => card.id === cardId) ?? -1;
const card = source?.cards[sourceIndex];
if (!source || !card || sourceIndex < 0) return null;
const next = stacks.map((stack) => ({ ...stack, cards: [...(stack.cards ?? [])] }));
const nextSource = next.find((stack) => stack.id === sourceStackId)!;
nextSource.cards.splice(sourceIndex, 1);
const nextTarget = next.find((stack) => stack.id === targetStackId);
if (!nextTarget) return null;
let targetIndex = requestedIndex;
if (sourceStackId === targetStackId && sourceIndex < targetIndex) targetIndex -= 1;
targetIndex = Math.max(0, Math.min(targetIndex, nextTarget.cards.length));
nextTarget.cards.splice(targetIndex, 0, { ...card, stackId: targetStackId });
next.forEach((stack) => stack.cards.forEach((item, order) => { item.order = order; }));
return { stacks: next, targetIndex };
}
export function moveStack(
stacks: Stack[],
stackId: number,
targetStackId: number,
after: boolean
): { stacks: Stack[]; targetIndex: number } | null {
const moved = stacks.find((stack) => stack.id === stackId);
if (!moved || !stacks.some((stack) => stack.id === targetStackId) || stackId === targetStackId) return null;
const next = stacks.filter((stack) => stack.id !== stackId).map((stack) => ({ ...stack }));
let targetIndex = next.findIndex((stack) => stack.id === targetStackId);
if (after) targetIndex += 1;
targetIndex = Math.max(0, targetIndex);
next.splice(targetIndex, 0, { ...moved });
next.forEach((stack, order) => { stack.order = order; });
return { stacks: next, targetIndex };
}

31
app/src/lib/settings.ts Normal file
View file

@ -0,0 +1,31 @@
import type { AppSettings } from './types';
const key = 'decky.settings.v2';
export const defaultSettings: AppSettings = {
serverUrl: '',
username: '',
sidebarCollapsed: false,
boardViews: {},
activeColor: 'E69A88',
completedColor: '80C3A5'
};
export function loadSettings(): AppSettings {
try {
const value = localStorage.getItem(key);
if (!value) return structuredClone(defaultSettings);
const parsed = JSON.parse(value) as Partial<AppSettings>;
return {
...structuredClone(defaultSettings),
...parsed,
boardViews: parsed.boardViews ?? {}
};
} catch {
return structuredClone(defaultSettings);
}
}
export function saveSettings(settings: AppSettings): void {
localStorage.setItem(key, JSON.stringify(settings));
}

63
app/src/lib/types.ts Normal file
View file

@ -0,0 +1,63 @@
export type BoardOrientation = 'horizontal' | 'vertical';
export interface DeckUser {
primaryKey?: string;
uid?: string;
displayname?: string;
}
export interface Board {
id: number;
title: string;
owner?: DeckUser;
color?: string | null;
archived?: boolean;
lastModified?: number;
deletedAt?: number;
}
export interface Stack {
id: number;
boardId: number;
title: string;
order: number;
deletedAt?: number;
cards: Card[];
}
export interface Card {
id: number;
stackId: number;
title: string;
description?: string | null;
type: string;
owner: string | DeckUser;
order: number;
archived?: boolean;
done?: string | null;
duedate?: string | null;
startdate?: string | null;
color?: string | null;
lastModified?: number;
}
export interface CardDraft {
title: string;
description: string;
dueDate: string;
color: string;
}
export interface BoardViewSettings {
orientation: BoardOrientation;
}
export interface AppSettings {
serverUrl: string;
username: string;
selectedBoardId?: number;
sidebarCollapsed: boolean;
boardViews: Record<number, BoardViewSettings>;
activeColor: string;
completedColor: string;
}

5
app/src/main.ts Normal file
View file

@ -0,0 +1,5 @@
import { mount } from 'svelte';
import App from './App.svelte';
import './app.css';
mount(App, { target: document.getElementById('app')! });

1
app/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

5
app/svelte.config.js Normal file
View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess()
};

14
app/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"sourceMap": true,
"strict": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}

14
app/vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: '127.0.0.1'
},
envPrefix: ['VITE_', 'TAURI_ENV_*'],
build: { target: 'es2022' }
});

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller
xmlns="http://schemas.microsoft.com/appx/appinstaller/2021"
Version="${VERSION}"
Uri="${PUBLIC_BASE_URL}/Decky.appinstaller">
<MainBundle
Name="com.elijahkuntz.Decky"
Publisher="${PUBLISHER_SUBJECT}"
Version="${VERSION}"
Uri="${PUBLIC_BASE_URL}/Decky_${VERSION}.msixbundle" />
<UpdateSettings>
<OnLaunch HoursBetweenUpdateChecks="12" ShowPrompt="true" UpdateBlocksActivation="false" />
<AutomaticBackgroundTask />
</UpdateSettings>
</AppInstaller>

View file

@ -1,15 +1,6 @@
# Packaging and updates # Retired MSIX prototype
`Decky.appinstaller.template` is the stable update-feed template. The release The original WinUI/MSIX packaging design was retired when Decky moved to Tauri
pipeline will replace its placeholders after producing and signing a versioned 2. Windows releases now use Tauri's per-user NSIS installer and signed updater.
MSIX bundle.
The final `PUBLIC_BASE_URL` must be an HTTPS location that Windows App Installer See [`app/UPDATES.md`](../app/UPDATES.md) and the scripts in [`scripts/`](../scripts/).
can read without an interactive sign-in. Package name and publisher must remain
stable across releases, and `VERSION` must be a monotonically increasing
four-part MSIX version such as `1.2.0.0`.
The application project is temporarily configured as unpackaged so the first
read-only shell can be developed before signing identity and public artifact URL
are chosen. MSIX packaging becomes the release configuration, not a different
application or update mechanism.

11
releases/latest.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"notes": "First realistic-testing release of Decky with Nextcloud Deck editing, live drag-and-drop, and signed in-app updates.",
"pub_date": "2026-07-19T22:25:48.1776589+00:00",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTSHZBY0NPTW9uRERoeFMyZWZLU2pRWmFudFh1ZWJKZVhJOUFRd2pMSVZGdmFYdUxwQUt5ZFFGcnQ5ek5FNGRZMmluMWIzdUJVZEIvTU15OGw1QXEySmh1ZWFJL2ZNUWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0NDk5ODgyCWZpbGU6RGVja3lfMC4yLjBfeDY0LXNldHVwLmV4ZQpKK1F2dFg0QWdoZ3dZcHpoTm9jTmIvaDVjeHJuQ1dhWTh3L0NJUEpQcnBmTmRWTk1Va3prSkVBdHo3V2RIMVlvMkxYMDdsZTY5M095Zjg0NjMrTHRBdz09Cg==",
"url": "https://git.elijahkuntz.com/Elijah/Decky/releases/download/v0.2.0/Decky_0.2.0_x64-setup.exe"
}
}
}

View file

@ -0,0 +1,94 @@
#requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string]$Version,
[string]$Notes = "Decky $Version",
[string]$ForgejoBaseUrl = 'https://git.elijahkuntz.com',
[string]$Repository = 'Elijah/Decky',
[string]$SigningKeyPath = "$env:USERPROFILE\.tauri\decky.key",
[string]$SigningKeyPasswordPath = "$env:USERPROFILE\.tauri\decky.key.password",
[switch]$SkipValidation,
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$appRoot = Join-Path $repositoryRoot 'app'
$tauriConfigPath = Join-Path $appRoot 'src-tauri\tauri.conf.json'
$cargoManifestPath = Join-Path $appRoot 'src-tauri\Cargo.toml'
$packagePath = Join-Path $appRoot 'package.json'
$releaseDirectory = Join-Path $repositoryRoot "artifacts\release\v$Version"
$packageVersion = (Get-Content -Raw -LiteralPath $packagePath | ConvertFrom-Json).version
$tauriVersion = (Get-Content -Raw -LiteralPath $tauriConfigPath | ConvertFrom-Json).version
$cargoVersion = [regex]::Match((Get-Content -Raw -LiteralPath $cargoManifestPath), '(?m)^version\s*=\s*"([^"]+)"').Groups[1].Value
if (@($packageVersion, $tauriVersion, $cargoVersion) | Where-Object { $_ -ne $Version }) {
throw "Version mismatch. package.json=$packageVersion, tauri.conf.json=$tauriVersion, Cargo.toml=$cargoVersion; expected $Version."
}
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
if (-not (Test-Path -LiteralPath $SigningKeyPath)) {
throw "Updater signing key not found at $SigningKeyPath. Do not generate a replacement after shipping a release."
}
$env:TAURI_SIGNING_PRIVATE_KEY = $SigningKeyPath
}
if (-not $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD -and (Test-Path -LiteralPath $SigningKeyPasswordPath)) {
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = (Get-Content -Raw -LiteralPath $SigningKeyPasswordPath)
}
if (-not $SkipBuild) {
Push-Location $appRoot
try {
if (-not $SkipValidation) {
& npm.cmd ci
if ($LASTEXITCODE) { throw 'npm ci failed.' }
& npm.cmd run check
if ($LASTEXITCODE) { throw 'Svelte validation failed.' }
& npm.cmd test
if ($LASTEXITCODE) { throw 'Tests failed.' }
}
& npm.cmd run tauri build
if ($LASTEXITCODE) { throw 'Tauri release build failed.' }
} finally {
Pop-Location
}
}
$nsisDirectory = Join-Path $appRoot 'src-tauri\target\release\bundle\nsis'
$installer = Get-ChildItem -LiteralPath $nsisDirectory -Filter '*-setup.exe' | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
if (-not $installer) { throw "No NSIS installer was found under $nsisDirectory." }
$signaturePath = "$($installer.FullName).sig"
if (-not (Test-Path -LiteralPath $signaturePath)) { throw "Updater signature was not generated: $signaturePath" }
New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null
$publishedInstaller = Join-Path $releaseDirectory $installer.Name
$publishedSignature = "$publishedInstaller.sig"
Copy-Item -LiteralPath $installer.FullName -Destination $publishedInstaller -Force
Copy-Item -LiteralPath $signaturePath -Destination $publishedSignature -Force
$tag = "v$Version"
$escapedInstallerName = [Uri]::EscapeDataString($installer.Name)
$downloadUrl = "$($ForgejoBaseUrl.TrimEnd('/'))/$Repository/releases/download/$tag/$escapedInstallerName"
$metadata = [ordered]@{
version = $Version
notes = $Notes
pub_date = [DateTimeOffset]::UtcNow.ToString('o')
platforms = [ordered]@{
'windows-x86_64' = [ordered]@{
signature = (Get-Content -Raw -LiteralPath $signaturePath).Trim()
url = $downloadUrl
}
}
}
$latestJsonPath = Join-Path $releaseDirectory 'latest.json'
$metadataJson = $metadata | ConvertTo-Json -Depth 6
[IO.File]::WriteAllText($latestJsonPath, $metadataJson, (New-Object Text.UTF8Encoding($false)))
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $publishedInstaller).Hash
Write-Host "Release prepared: $releaseDirectory"
Write-Host "Installer: $publishedInstaller"
Write-Host "SHA256: $hash"
Write-Host "Updater metadata: $latestJsonPath"

View file

@ -0,0 +1,78 @@
#requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string]$Version,
[string]$Notes = "Decky $Version",
[string]$ForgejoBaseUrl = 'https://git.elijahkuntz.com',
[string]$Repository = 'Elijah/Decky',
[string]$Token = $env:FORGEJO_TOKEN
)
$ErrorActionPreference = 'Stop'
if (-not $Token) { throw 'FORGEJO_TOKEN is required. Store it as a Forgejo Actions secret or set it only for this process.' }
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$releaseDirectory = Join-Path $repositoryRoot "artifacts\release\v$Version"
$latestJsonPath = Join-Path $releaseDirectory 'latest.json'
if (-not (Test-Path -LiteralPath $latestJsonPath)) { throw "Run Prepare-Release.ps1 first: $latestJsonPath is missing." }
$headers = @{ Authorization = "token $Token"; Accept = 'application/json' }
$apiRoot = "$($ForgejoBaseUrl.TrimEnd('/'))/api/v1/repos/$Repository"
$tag = "v$Version"
$releaseBody = @{
tag_name = $tag
target_commitish = 'main'
name = "Decky $Version"
body = $Notes
draft = $false
prerelease = $Version.Contains('-')
} | ConvertTo-Json
try {
$release = Invoke-RestMethod -Method Get -Uri "$apiRoot/releases/tags/$tag" -Headers $headers
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
$release = Invoke-RestMethod -Method Post -Uri "$apiRoot/releases" -Headers $headers -ContentType 'application/json' -Body $releaseBody
}
$assets = Get-ChildItem -LiteralPath $releaseDirectory -File
foreach ($asset in $assets) {
$existing = @($release.assets | Where-Object name -eq $asset.Name)
if ($existing.Count) {
Write-Host "Release asset already exists, skipping: $($asset.Name)"
continue
}
Add-Type -AssemblyName System.Net.Http
$client = New-Object Net.Http.HttpClient
try {
$client.DefaultRequestHeaders.Authorization = New-Object Net.Http.Headers.AuthenticationHeaderValue('token', $Token)
$form = New-Object Net.Http.MultipartFormDataContent
$fileContent = New-Object Net.Http.ByteArrayContent(,[IO.File]::ReadAllBytes($asset.FullName))
$fileContent.Headers.ContentType = New-Object Net.Http.Headers.MediaTypeHeaderValue('application/octet-stream')
$form.Add($fileContent, 'attachment', $asset.Name)
$response = $client.PostAsync("$apiRoot/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($asset.Name))", $form).GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) { throw "Asset upload failed ($([int]$response.StatusCode)): $($response.Content.ReadAsStringAsync().GetAwaiter().GetResult())" }
} finally {
if ($form) { $form.Dispose() }
$client.Dispose()
}
Write-Host "Uploaded $($asset.Name)"
}
$encodedMetadata = [Convert]::ToBase64String([IO.File]::ReadAllBytes($latestJsonPath))
$contentsUri = "$apiRoot/contents/releases/latest.json?ref=main"
$sha = $null
try { $sha = (Invoke-RestMethod -Method Get -Uri $contentsUri -Headers $headers).sha } catch {
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
}
$contentBody = @{
branch = 'main'
message = "chore(release): publish Decky $Version updater metadata"
content = $encodedMetadata
}
if ($sha) { $contentBody.sha = $sha }
Invoke-RestMethod -Method Put -Uri "$apiRoot/contents/releases/latest.json" -Headers $headers -ContentType 'application/json' -Body ($contentBody | ConvertTo-Json) | Out-Null
Write-Host "Published Decky $Version and updated releases/latest.json."

31
scripts/Set-Version.ps1 Normal file
View file

@ -0,0 +1,31 @@
#requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string]$Version
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$appRoot = Join-Path $repositoryRoot 'app'
Push-Location $appRoot
try {
& npm.cmd version $Version --no-git-tag-version --allow-same-version
if ($LASTEXITCODE) { throw 'npm could not update package.json and package-lock.json.' }
} finally {
Pop-Location
}
$tauriConfigPath = Join-Path $appRoot 'src-tauri\tauri.conf.json'
$tauriConfig = Get-Content -Raw -LiteralPath $tauriConfigPath
$tauriConfig = [regex]::Replace($tauriConfig, '(?m)(^\s*"version"\s*:\s*")[^"]+("\s*,)', "`${1}$Version`${2}", 1)
[IO.File]::WriteAllText($tauriConfigPath, $tauriConfig, (New-Object Text.UTF8Encoding($false)))
$cargoManifestPath = Join-Path $appRoot 'src-tauri\Cargo.toml'
$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."

View file

@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json;
using Decky.Core.Api; using Decky.Core.Api;
using Decky.Core.Api.Models; using Decky.Core.Api.Models;
using Decky.Core.Api.Requests; using Decky.Core.Api.Requests;
@ -26,23 +27,43 @@ try
var boards = await client.GetBoardsAsync(); var boards = await client.GetBoardsAsync();
Console.WriteLine($"Connected through {client.ApiBaseUri}"); Console.WriteLine($"Connected through {client.ApiBaseUri}");
Console.WriteLine($"Found {boards.Count} accessible board(s).\n"); Console.WriteLine($"Found {boards.Count} board entry/entries.\n");
var readableBoards = new List<BoardDto>();
foreach (var board in boards.OrderBy(board => board.Title, StringComparer.CurrentCultureIgnoreCase)) foreach (var board in boards.OrderBy(board => board.Title, StringComparer.CurrentCultureIgnoreCase))
{ {
var stacks = await client.GetStacksAsync(board.Id); if (board.Archived || board.DeletedAt != 0)
var cardCount = stacks.Sum(stack => stack.Cards.Count); {
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)"); Console.WriteLine($"[{board.Id}] {board.Title}: skipped ({(board.Archived ? "archived" : "deleted")})");
continue;
}
try
{
var stacks = await client.GetStacksAsync(board.Id);
var cardCount = stacks.Sum(stack => stack.Cards.Count);
readableBoards.Add(board);
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
}
catch (DeckApiException exception) when (exception.StatusCode == HttpStatusCode.Forbidden)
{
Console.WriteLine($"[{board.Id}] {board.Title}: unavailable (403 Permission denied)");
}
} }
Console.WriteLine("\nPASS Authentication, board retrieval, stack retrieval, and card-list retrieval"); if (readableBoards.Count == 0)
{
throw new InvalidOperationException("Nextcloud did not return any boards whose stacks can be read.");
}
Console.WriteLine($"\nPASS Authentication and read access to {readableBoards.Count} board(s)");
Console.WriteLine("\nOptional write tests create and delete one temporary card, but can reorder that card"); Console.WriteLine("\nOptional write tests create and delete one temporary card, but can reorder that card");
Console.WriteLine("inside the selected board. Use a disposable board with at least two stacks."); Console.WriteLine("inside the selected board. Use a disposable board with at least two stacks.");
Console.Write("Type WRITE to run mutation compatibility checks, or press Enter to finish: "); Console.Write("Type WRITE to run mutation compatibility checks, or press Enter to finish: ");
if (string.Equals(Console.ReadLine(), "WRITE", StringComparison.Ordinal)) if (string.Equals(Console.ReadLine(), "WRITE", StringComparison.Ordinal))
{ {
var writeTestsPassed = await RunWriteProbeAsync(client, boards); var writeTestsPassed = await RunWriteProbeAsync(client, readableBoards);
Environment.ExitCode = writeTestsPassed ? 0 : 2; Environment.ExitCode = writeTestsPassed ? 0 : 2;
} }
else else
@ -50,7 +71,8 @@ try
Console.WriteLine("\nRead-only API compatibility checks passed."); Console.WriteLine("\nRead-only API compatibility checks passed.");
} }
} }
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException) catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException or InvalidOperationException)
{ {
Console.Error.WriteLine($"\nCompatibility check failed: {exception.Message}"); Console.Error.WriteLine($"\nCompatibility check failed: {exception.Message}");
Environment.ExitCode = 1; Environment.ExitCode = 1;
@ -161,7 +183,7 @@ static async Task<bool> RunWriteProbeAsync(DeckApiClient client, IReadOnlyList<B
return true; return true;
} }
catch (Exception exception) when ( catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException) exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException or JsonException)
{ {
Console.Error.WriteLine($"FAIL Mutation compatibility check: {exception.Message}"); Console.Error.WriteLine($"FAIL Mutation compatibility check: {exception.Message}");
return false; return false;

View file

@ -1,4 +1,5 @@
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Decky.App.Services;
namespace Decky.App; namespace Decky.App;
@ -8,13 +9,35 @@ public partial class App : Application
public App() public App()
{ {
UnhandledException += (_, args) => WriteStartupError(args.Exception);
InitializeComponent(); InitializeComponent();
} }
protected override void OnLaunched(LaunchActivatedEventArgs args) protected override void OnLaunched(LaunchActivatedEventArgs args)
{ {
_window = new MainWindow(); try
_window.Activate(); {
_window = new MainWindow();
_window.Activate();
}
catch (Exception exception)
{
WriteStartupError(exception);
throw;
}
}
private static void WriteStartupError(Exception exception)
{
try
{
var logPath = Path.Combine(Path.GetDirectoryName(AppDataPaths.SettingsFile)!, "startup-error.log");
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
File.WriteAllText(logPath, $"{DateTimeOffset.Now:O}{Environment.NewLine}{exception}");
}
catch
{
// Startup logging must never replace the original exception.
}
} }
} }

View file

@ -11,6 +11,8 @@
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<SelfContained>true</SelfContained>
<EnableMsixTooling>true</EnableMsixTooling>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

View file

@ -82,7 +82,18 @@
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
VerticalAlignment="Top"> VerticalAlignment="Top">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold" /> <Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1" Click="NewCardButton_OnClick"
ToolTipService.ToolTip="Create card">
<FontIcon Glyph="&#xE710;" FontSize="14" />
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding Cards}"> <ItemsControl ItemsSource="{Binding Cards}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
@ -93,6 +104,16 @@
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" /> Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
<TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75" <TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75"
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" /> Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="4">
<Button Click="EditCardButton_OnClick"
ToolTipService.ToolTip="Edit card">
<FontIcon Glyph="&#xE70F;" FontSize="12" />
</Button>
<Button Click="DeleteCardButton_OnClick"
ToolTipService.ToolTip="Delete card">
<FontIcon Glyph="&#xE74D;" FontSize="12" />
</Button>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>

View file

@ -1,6 +1,8 @@
using System.ComponentModel; using System.ComponentModel;
using Decky.App.Services; using Decky.App.Services;
using Decky.App.ViewModels; using Decky.App.ViewModels;
using Decky.App.Views;
using Decky.Core.Api.Models;
using Decky.Core.Services; using Decky.Core.Services;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
@ -32,6 +34,65 @@ public sealed partial class MainWindow : Window
_viewModel.AppPassword = ((PasswordBox)sender).Password; _viewModel.AppPassword = ((PasswordBox)sender).Password;
} }
private async void NewCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not StackDto stack)
{
return;
}
var editor = new CardEditorDialog();
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
if (result is not null)
{
await _viewModel.CreateCardAsync(stack.Id, result);
}
}
private async void EditCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not CardDto card)
{
return;
}
var fullCard = await _viewModel.GetCardDetailsAsync(card);
if (fullCard is null)
{
return;
}
var editor = new CardEditorDialog(fullCard);
var result = await editor.ShowForResultAsync(RootGrid.XamlRoot);
if (result is not null)
{
await _viewModel.UpdateCardAsync(fullCard, result);
}
}
private async void DeleteCardButton_OnClick(object sender, RoutedEventArgs e)
{
if (((Button)sender).DataContext is not CardDto card)
{
return;
}
var confirmation = new ContentDialog
{
XamlRoot = RootGrid.XamlRoot,
Title = "Delete card?",
Content = $"“{card.Title}” will be deleted from Nextcloud.",
PrimaryButtonText = "Delete",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Close,
};
if (await confirmation.ShowAsync() == ContentDialogResult.Primary)
{
await _viewModel.DeleteCardAsync(card);
}
}
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e) private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(MainViewModel.AppPassword) && if (e.PropertyName == nameof(MainViewModel.AppPassword) &&

View file

@ -0,0 +1,9 @@
namespace Decky.App.ViewModels;
public sealed record CardEditorResult(
string Title,
string Description,
DateTimeOffset? DueDate,
string Color,
bool IsCompleted);

View file

@ -1,9 +1,12 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Concurrent;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Decky.Core.Api; using Decky.Core.Api;
using Decky.Core.Api.Models; using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
using Decky.Core.Configuration; using Decky.Core.Configuration;
using Decky.Core.Models; using Decky.Core.Models;
using Decky.Core.Services; using Decky.Core.Services;
@ -15,6 +18,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly ICredentialService _credentialService; private readonly ICredentialService _credentialService;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _mutationLocks = new();
private DeckApiClient? _apiClient; private DeckApiClient? _apiClient;
private CancellationTokenSource? _loadCancellation; private CancellationTokenSource? _loadCancellation;
private AppSettings _settings = new(); private AppSettings _settings = new();
@ -160,7 +164,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
var boards = await _apiClient.GetBoardsAsync(); var boards = await _apiClient.GetBoardsAsync();
Boards.Clear(); Boards.Clear();
foreach (var board in boards.Where(board => !board.Archived).OrderBy(board => board.Title)) foreach (var board in boards
.Where(board => !board.Archived && board.DeletedAt == 0)
.OrderBy(board => board.Title))
{ {
Boards.Add(board); Boards.Add(board);
} }
@ -184,7 +190,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
await SaveSelectedBoardAsync(boardToOpen.Id); await SaveSelectedBoardAsync(boardToOpen.Id);
} }
} }
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException) catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or ArgumentException or JsonException)
{ {
IsConnected = false; IsConnected = false;
StatusMessage = exception.Message; StatusMessage = exception.Message;
@ -261,6 +268,126 @@ public partial class MainViewModel : ObservableObject, IDisposable
private bool CanRefresh() => !IsBusy && _apiClient is not null && SelectedBoard is not null; private bool CanRefresh() => !IsBusy && _apiClient is not null && SelectedBoard is not null;
public async Task<CardDto?> GetCardDetailsAsync(CardDto card)
{
if (_apiClient is null || SelectedBoard is null)
{
return null;
}
IsBusy = true;
StatusMessage = $"Loading {card.Title}…";
try
{
return await _apiClient.GetCardAsync(SelectedBoard.Id, card.StackId, card.Id);
}
catch (Exception exception) when (IsApiException(exception))
{
StatusMessage = $"Card details could not be loaded: {exception.Message}";
return null;
}
finally
{
IsBusy = false;
}
}
public Task<bool> CreateCardAsync(int stackId, CardEditorResult editor) =>
RunBoardMutationAsync(
async (client, boardId) =>
{
var stack = Stacks.FirstOrDefault(item => item.Id == stackId)
?? throw new InvalidOperationException("The destination stack is no longer loaded.");
var nextOrder = stack.Cards.Count == 0 ? 0 : stack.Cards.Max(card => card.Order) + 1;
var created = await client.CreateCardAsync(
boardId,
stackId,
new CreateCardRequest
{
Title = editor.Title,
Description = editor.Description,
DueDate = editor.DueDate,
Color = editor.Color,
Order = nextOrder,
});
if (editor.IsCompleted)
{
var fullCard = await client.GetCardAsync(boardId, stackId, created.Id);
await client.UpdateCardAsync(
boardId,
stackId,
created.Id,
CardUpdateRequest.FromCard(
fullCard,
done: DateTimeOffset.UtcNow,
replaceDone: true));
}
},
"Card created");
public Task<bool> UpdateCardAsync(CardDto card, CardEditorResult editor) =>
RunBoardMutationAsync(
async (client, boardId) =>
{
DateTimeOffset? done = editor.IsCompleted ? card.Done ?? DateTimeOffset.UtcNow : null;
var request = CardUpdateRequest.FromCard(
card,
title: editor.Title,
description: editor.Description,
replaceDescription: true,
dueDate: editor.DueDate,
replaceDueDate: true,
done: done,
replaceDone: true,
color: editor.Color);
await client.UpdateCardAsync(boardId, card.StackId, card.Id, request);
},
"Card updated");
public Task<bool> DeleteCardAsync(CardDto card) =>
RunBoardMutationAsync(
(client, boardId) => client.DeleteCardAsync(boardId, card.StackId, card.Id),
"Card deleted");
private async Task<bool> RunBoardMutationAsync(
Func<DeckApiClient, int, Task> mutation,
string successMessage)
{
var client = _apiClient;
var board = SelectedBoard;
if (client is null || board is null)
{
StatusMessage = "Connect and select a board before editing cards.";
return false;
}
var mutationLock = _mutationLocks.GetOrAdd(board.Id, _ => new SemaphoreSlim(1, 1));
await mutationLock.WaitAsync();
IsBusy = true;
StatusMessage = "Saving card…";
try
{
await mutation(client, board.Id);
await LoadBoardAsync(board);
StatusMessage = successMessage;
return true;
}
catch (Exception exception) when (IsApiException(exception) || exception is InvalidOperationException)
{
StatusMessage = $"The card change failed: {exception.Message}";
return false;
}
finally
{
IsBusy = false;
mutationLock.Release();
}
}
private static bool IsApiException(Exception exception) =>
exception is DeckApiException or HttpRequestException or JsonException or ArgumentException;
private async Task LoadBoardAsync(BoardDto board) private async Task LoadBoardAsync(BoardDto board)
{ {
if (_apiClient is null) if (_apiClient is null)
@ -296,7 +423,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
} }
catch (Exception exception) when (exception is DeckApiException or HttpRequestException) catch (Exception exception) when (exception is DeckApiException or HttpRequestException or JsonException)
{ {
StatusMessage = $"Offline · {exception.Message}"; StatusMessage = $"Offline · {exception.Message}";
} }
@ -319,6 +446,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
disposableSettingsService.Dispose(); disposableSettingsService.Dispose();
} }
foreach (var mutationLock in _mutationLocks.Values)
{
mutationLock.Dispose();
}
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }

Some files were not shown because too many files have changed in this diff Show more