Initial commit of version 0.2.0
43
.forgejo/workflows/release.yml
Normal 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
|
|
@ -10,3 +10,8 @@ artifacts/
|
|||
appsettings.local.json
|
||||
.dotnet-home/
|
||||
.packages/
|
||||
app/node_modules/
|
||||
app/dist/
|
||||
app/src-tauri/target/
|
||||
app/src-tauri/release.conf.json
|
||||
.tauri-signing/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# 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)
|
||||
|
||||
The overall direction remains appropriate: a C# WinUI 3 client, an API-first core,
|
||||
|
|
|
|||
80
README.md
|
|
@ -1,41 +1,71 @@
|
|||
# Decky
|
||||
|
||||
Decky is a focused Windows desktop client for viewing and editing boards from a
|
||||
Nextcloud Deck instance.
|
||||
Decky is a focused cross-platform desktop client for viewing and editing boards
|
||||
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;
|
||||
- `Decky.ApiSpike`, a read-only connection and compatibility probe;
|
||||
- `Decky.App`, the initial WinUI 3 desktop shell;
|
||||
- focused tests for API path discovery, required headers, update serialization,
|
||||
completion clearing, color preservation, and text contrast.
|
||||
- Tauri 2 for the small native desktop shell;
|
||||
- Svelte 5 and TypeScript for the interface;
|
||||
- the operating system webview on Windows, macOS, and Linux;
|
||||
- the native credential store on each platform (Windows Credential Manager,
|
||||
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
|
||||
|
||||
Install the .NET 10 SDK and the Visual Studio WinUI application-development
|
||||
workload. Then run:
|
||||
Install Node.js, Rust stable, the platform-specific Tauri prerequisites, and
|
||||
then run:
|
||||
|
||||
```powershell
|
||||
dotnet restore Decky.slnx
|
||||
dotnet test tests/Decky.Core.Tests/Decky.Core.Tests.csproj
|
||||
dotnet run --project src/Decky.ApiSpike/Decky.ApiSpike.csproj
|
||||
cd app
|
||||
npm install
|
||||
npm run check
|
||||
npm test
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
The compatibility probe prompts for a Nextcloud app password without echoing or
|
||||
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.
|
||||
Build a release executable with:
|
||||
|
||||
The desktop app stores its JSON settings in `%LOCALAPPDATA%\Decky\settings.json`.
|
||||
The app password is stored separately in Windows Credential Locker and is never
|
||||
written to the JSON file.
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\Prepare-Release.ps1 -Version 0.2.0 -Notes "Release notes"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Release builds will be distributed as a signed MSIX bundle through a stable
|
||||
`Decky.appinstaller` feed. Forgejo is the source and CI/release host; installed
|
||||
copies do not update with `git pull`.
|
||||
Installed copies update from signed release artifacts, never with `git pull`.
|
||||
The updater is available from Settings and reads stable metadata from the
|
||||
Forgejo repository. See `app/UPDATES.md` for signing-key backup, release tags,
|
||||
Forgejo Actions secrets, and the manual release fallback.
|
||||
|
|
|
|||
82
app/UPDATES.md
Normal 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
|
|
@ -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
31
app/package.json
Normal 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
22
app/src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
23
app/src-tauri/capabilities/default.json
Normal 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:**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
app/src-tauri/gen/schemas/capabilities.json
Normal 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:**"}]}]}}
|
||||
2580
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2580
app/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
app/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
app/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -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>
|
||||
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
app/src-tauri/icons/icon.icns
Normal file
BIN
app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
13
app/src-tauri/icons/icon.svg
Normal 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 |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 740 B |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 926 B |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
45
app/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
5
app/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
decky_lib::run();
|
||||
}
|
||||
59
app/src-tauri/tauri.conf.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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)}`;
|
||||
}
|
||||
16
app/src/lib/credentials.ts
Normal 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 });
|
||||
}
|
||||
99
app/src/lib/deck-api.test.ts
Normal 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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
57
app/src/lib/reorder.test.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
5
app/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
14
app/tsconfig.json
Normal 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
|
|
@ -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' }
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
# Packaging and updates
|
||||
# Retired MSIX prototype
|
||||
|
||||
`Decky.appinstaller.template` is the stable update-feed template. The release
|
||||
pipeline will replace its placeholders after producing and signing a versioned
|
||||
MSIX bundle.
|
||||
The original WinUI/MSIX packaging design was retired when Decky moved to Tauri
|
||||
2. Windows releases now use Tauri's per-user NSIS installer and signed updater.
|
||||
|
||||
The final `PUBLIC_BASE_URL` must be an HTTPS location that Windows App Installer
|
||||
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.
|
||||
See [`app/UPDATES.md`](../app/UPDATES.md) and the scripts in [`scripts/`](../scripts/).
|
||||
|
|
|
|||
11
releases/latest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
94
scripts/Prepare-Release.ps1
Normal 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"
|
||||
78
scripts/Publish-ForgejoRelease.ps1
Normal 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
|
|
@ -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."
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Decky.Core.Api;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Api.Requests;
|
||||
|
|
@ -26,23 +27,43 @@ try
|
|||
var boards = await client.GetBoardsAsync();
|
||||
|
||||
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))
|
||||
{
|
||||
if (board.Archived || board.DeletedAt != 0)
|
||||
{
|
||||
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("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: ");
|
||||
|
||||
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;
|
||||
}
|
||||
else
|
||||
|
|
@ -50,7 +71,8 @@ try
|
|||
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}");
|
||||
Environment.ExitCode = 1;
|
||||
|
|
@ -161,7 +183,7 @@ static async Task<bool> RunWriteProbeAsync(DeckApiClient client, IReadOnlyList<B
|
|||
return true;
|
||||
}
|
||||
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}");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.UI.Xaml;
|
||||
using Decky.App.Services;
|
||||
|
||||
namespace Decky.App;
|
||||
|
||||
|
|
@ -8,13 +9,35 @@ public partial class App : Application
|
|||
|
||||
public App()
|
||||
{
|
||||
UnhandledException += (_, args) => WriteStartupError(args.Exception);
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
_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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
<UseWinUI>true</UseWinUI>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<SelfContained>true</SelfContained>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
|
|
|
|||
|
|
@ -82,7 +82,18 @@
|
|||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||
VerticalAlignment="Top">
|
||||
<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="" FontSize="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding Cards}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
|
@ -93,6 +104,16 @@
|
|||
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
|
||||
<TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75"
|
||||
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="4">
|
||||
<Button Click="EditCardButton_OnClick"
|
||||
ToolTipService.ToolTip="Edit card">
|
||||
<FontIcon Glyph="" FontSize="12" />
|
||||
</Button>
|
||||
<Button Click="DeleteCardButton_OnClick"
|
||||
ToolTipService.ToolTip="Delete card">
|
||||
<FontIcon Glyph="" FontSize="12" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using System.ComponentModel;
|
||||
using Decky.App.Services;
|
||||
using Decky.App.ViewModels;
|
||||
using Decky.App.Views;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Services;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
|
@ -32,6 +34,65 @@ public sealed partial class MainWindow : Window
|
|||
_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)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.AppPassword) &&
|
||||
|
|
|
|||
9
src/Decky.App/ViewModels/CardEditorResult.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Decky.App.ViewModels;
|
||||
|
||||
public sealed record CardEditorResult(
|
||||
string Title,
|
||||
string Description,
|
||||
DateTimeOffset? DueDate,
|
||||
string Color,
|
||||
bool IsCompleted);
|
||||
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Decky.Core.Api;
|
||||
using Decky.Core.Api.Models;
|
||||
using Decky.Core.Api.Requests;
|
||||
using Decky.Core.Configuration;
|
||||
using Decky.Core.Models;
|
||||
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 ISettingsService _settingsService;
|
||||
private readonly ICredentialService _credentialService;
|
||||
private readonly ConcurrentDictionary<int, SemaphoreSlim> _mutationLocks = new();
|
||||
private DeckApiClient? _apiClient;
|
||||
private CancellationTokenSource? _loadCancellation;
|
||||
private AppSettings _settings = new();
|
||||
|
|
@ -160,7 +164,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
var boards = await _apiClient.GetBoardsAsync();
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -184,7 +190,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
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;
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
if (_apiClient is null)
|
||||
|
|
@ -296,7 +423,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
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}";
|
||||
}
|
||||
|
|
@ -319,6 +446,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
|||
disposableSettingsService.Dispose();
|
||||
}
|
||||
|
||||
foreach (var mutationLock in _mutationLocks.Values)
|
||||
{
|
||||
mutationLock.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||