Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ad208714 | ||
|
|
d3d9355191 | ||
|
|
ee192a15e7 | ||
|
|
22a43fdef7 | ||
|
|
bbfe968ff1 | ||
|
|
d1b26e625a | ||
|
|
838dfe2a6e | ||
|
|
3078906548 | ||
|
|
3eb5202aac | ||
|
|
c04eac06c4 | ||
|
|
bf81d046e6 | ||
|
|
195b0e343f | ||
|
|
ce6ca73ea0 | ||
|
|
088c225202 | ||
|
|
88108ecc1b | ||
|
|
b459159b0f | ||
|
|
7c113fd5ab | ||
|
|
c8835825e7 | ||
|
|
56c90225cb | ||
|
|
c16659c2ad | ||
|
|
a67bedc6f2 | ||
|
|
923de0cdd6 | ||
|
|
285e3b4abe | ||
|
|
9b4869812f | ||
|
|
7f08dc1a0d | ||
|
|
08f73dfd19 | ||
|
|
2059e50ce3 | ||
|
|
fe3399a44b | ||
|
|
f6cf7b4358 | ||
|
|
c8fe15b0c9 | ||
|
|
43c15b81a3 | ||
|
|
ccd0477841 | ||
|
|
ff3e0e8736 | ||
|
|
26ffa0e71f | ||
|
|
a29708df85 | ||
|
|
2aec2d8f44 |
No files matched your search
@@ -8,3 +8,6 @@
|
|||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.zip
|
*.zip
|
||||||
|
electron/node_modules
|
||||||
|
data/hyprcrd.db-shm
|
||||||
|
data/hyprcrd.db-wal
|
||||||
+4
-2
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.7.1');
|
define('APP_VERSION', '1.1.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
if (!defined('DB_PATH')) define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
if (!defined('UPLOAD_DIR')) define('UPLOAD_DIR', __DIR__ . '/public/uploads');
|
||||||
|
|
||||||
// Base URL auto-detection (works in Docker and hosted environments)
|
// Base URL auto-detection (works in Docker and hosted environments)
|
||||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
appdata/
|
||||||
|
php-bin/linux/php
|
||||||
|
php-bin/darwin/php
|
||||||
|
php-bin/win32/php.exe
|
||||||
|
php-bin/win32/*.dll
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# hyprcrd — Electron Desktop App
|
||||||
|
|
||||||
|
Wraps the PHP backend in an Electron shell. A static PHP binary is bundled
|
||||||
|
per platform so users don't need PHP installed.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. On launch, the app starts PHP's built-in server on a random port (`127.0.0.1`)
|
||||||
|
2. `router.php` replaces Apache's `.htaccess` rewrite rules
|
||||||
|
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
||||||
|
4. On quit, the PHP process is killed cleanly
|
||||||
|
|
||||||
|
Data is stored in `data/hyprcrd.db` (SQLite), relative to the project root
|
||||||
|
(dev) or inside the app's `resources/` directory (packaged).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd electron
|
||||||
|
npm install
|
||||||
|
npm start # launches the app using system or bundled PHP
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires PHP 8.1+ with `pdo_sqlite` and `zip` extensions — either on
|
||||||
|
the system PATH or as a static binary in `php-bin/<platform>/`.
|
||||||
|
|
||||||
|
## Bundling PHP
|
||||||
|
|
||||||
|
Static PHP binaries are not checked into git. Fetch them before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fetch-php.sh # downloads ~20MB per platform from static-php-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Or place them manually (see `php-bin/README.md` for sources):
|
||||||
|
|
||||||
|
php-bin/linux/php
|
||||||
|
php-bin/darwin/php
|
||||||
|
php-bin/win32/php.exe
|
||||||
|
|
||||||
|
The app prefers the bundled binary. If none is found, it falls back to
|
||||||
|
system PHP. If neither exists, a dialog tells the user what to install.
|
||||||
|
|
||||||
|
## Building distributables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `electron-builder`. Output:
|
||||||
|
|
||||||
|
| Platform | Format | Location |
|
||||||
|
|----------|----------|-------------------|
|
||||||
|
| macOS | `.dmg` | `dist/` |
|
||||||
|
| Windows | NSIS | `dist/` |
|
||||||
|
| Linux | AppImage | `dist/` |
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
electron/
|
||||||
|
main.js Electron main process — spawns PHP, creates window
|
||||||
|
preload.js Exposes window.hyprcrdElectron (platform, isElectron)
|
||||||
|
router.php PHP built-in server router (replaces .htaccess)
|
||||||
|
fetch-php.sh Downloads static PHP binaries for all platforms
|
||||||
|
php-bin/ Platform-specific PHP binaries (gitignored)
|
||||||
|
```
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download static PHP binaries for all platforms.
|
||||||
|
# Run from the electron/ directory.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/php-bin"
|
||||||
|
|
||||||
|
BASE="https://dl.static-php.dev/static-php-cli/common"
|
||||||
|
|
||||||
|
# Find latest 8.3.x version
|
||||||
|
echo "Finding latest PHP 8.3 version..."
|
||||||
|
VERSION=$(curl -sL "$BASE/" | grep -oP 'php-8\.3\.\d+-cli-linux-x86_64' | sort -V | tail -1 | grep -oP '8\.3\.\d+')
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "ERROR: Could not determine latest PHP 8.3 version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Using PHP $VERSION"
|
||||||
|
|
||||||
|
# Linux x86_64
|
||||||
|
echo " linux..."
|
||||||
|
curl -fSL "$BASE/php-${VERSION}-cli-linux-x86_64.tar.gz" -o /tmp/php-linux.tar.gz
|
||||||
|
tar -xzf /tmp/php-linux.tar.gz -C linux/
|
||||||
|
chmod +x linux/php
|
||||||
|
rm /tmp/php-linux.tar.gz
|
||||||
|
|
||||||
|
# macOS arm64
|
||||||
|
echo " darwin..."
|
||||||
|
curl -fSL "$BASE/php-${VERSION}-cli-macos-aarch64.tar.gz" -o /tmp/php-darwin.tar.gz
|
||||||
|
tar -xzf /tmp/php-darwin.tar.gz -C darwin/
|
||||||
|
chmod +x darwin/php
|
||||||
|
rm /tmp/php-darwin.tar.gz
|
||||||
|
|
||||||
|
# Windows — static-php-cli doesn't ship Windows builds,
|
||||||
|
# so we skip it here. For Windows, bundle PHP from https://windows.php.net/download/
|
||||||
|
echo " win32: skipped (no static build available — use windows.php.net)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done: PHP $VERSION"
|
||||||
|
ls -lh linux/php darwin/php 2>/dev/null
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
const { app, BrowserWindow, dialog, shell } = require('electron');
|
||||||
|
const { spawn, execFileSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let phpProcess = null;
|
||||||
|
let mainWindow = null;
|
||||||
|
let serverPort = 0;
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
|
||||||
|
function resolvePath(rel) {
|
||||||
|
if (isDev) return path.join(__dirname, '..', rel);
|
||||||
|
return path.join(process.resourcesPath, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBundledPhp() {
|
||||||
|
const exe = process.platform === 'win32' ? 'php.exe' : 'php';
|
||||||
|
const bundled = isDev
|
||||||
|
? path.join(__dirname, 'php-bin', process.platform, exe)
|
||||||
|
: path.join(process.resourcesPath, 'php-bin', exe);
|
||||||
|
if (fs.existsSync(bundled)) {
|
||||||
|
try {
|
||||||
|
execFileSync(bundled, ['-v'], { stdio: 'ignore', timeout: 3000 });
|
||||||
|
return bundled;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSystemPhp() {
|
||||||
|
const candidates = process.platform === 'win32'
|
||||||
|
? ['php.exe', 'C:\\php\\php.exe', 'C:\\xampp\\php\\php.exe']
|
||||||
|
: ['php', '/usr/bin/php', '/usr/local/bin/php', '/opt/homebrew/bin/php'];
|
||||||
|
for (const bin of candidates) {
|
||||||
|
try {
|
||||||
|
execFileSync(bin, ['-v'], { stdio: 'ignore', timeout: 3000 });
|
||||||
|
return bin;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPhp() {
|
||||||
|
return findBundledPhp() || findSystemPhp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPhpExtensions(phpBin) {
|
||||||
|
try {
|
||||||
|
const mods = execFileSync(phpBin, ['-m'], { timeout: 3000 }).toString().toLowerCase();
|
||||||
|
const missing = [];
|
||||||
|
for (const ext of ['pdo_sqlite', 'zip']) {
|
||||||
|
if (!mods.includes(ext)) missing.push(ext);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer();
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = srv.address().port;
|
||||||
|
srv.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
srv.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataDir() {
|
||||||
|
if (isDev) return path.join(__dirname, 'appdata');
|
||||||
|
return path.join(app.getPath('userData'), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDataDir() {
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
return dataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPhpServer() {
|
||||||
|
const phpBin = findPhp();
|
||||||
|
if (!phpBin) {
|
||||||
|
dialog.showErrorBox('PHP not found',
|
||||||
|
'hyprcrd needs PHP 8.1+ with pdo_sqlite.\n\n' +
|
||||||
|
'Install PHP and make sure it\'s in your PATH.\n\n' +
|
||||||
|
(process.platform === 'darwin' ? 'brew install php' :
|
||||||
|
process.platform === 'win32' ? 'https://windows.php.net/download/' :
|
||||||
|
'sudo apt install php php-sqlite3'));
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = checkPhpExtensions(phpBin);
|
||||||
|
if (missing.length) {
|
||||||
|
dialog.showErrorBox('Missing PHP extensions',
|
||||||
|
`hyprcrd needs these PHP extensions: ${missing.join(', ')}\n\n` +
|
||||||
|
(process.platform === 'darwin' ? 'They are included with "brew install php".' :
|
||||||
|
process.platform === 'win32' ? 'Enable them in php.ini (uncomment extension=...).' :
|
||||||
|
`Install with: sudo apt install ${missing.map(e => 'php-' + e.replace('pdo_', '')).join(' ')}`));
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = ensureDataDir();
|
||||||
|
serverPort = await getFreePort();
|
||||||
|
|
||||||
|
const docRoot = resolvePath('public');
|
||||||
|
const routerFile = isDev
|
||||||
|
? path.join(__dirname, 'router.php')
|
||||||
|
: path.join(process.resourcesPath, 'router.php');
|
||||||
|
|
||||||
|
console.log('[hyprcrd] PHP:', phpBin);
|
||||||
|
console.log('[hyprcrd] docRoot:', docRoot);
|
||||||
|
console.log('[hyprcrd] router:', routerFile);
|
||||||
|
console.log('[hyprcrd] data:', dataDir);
|
||||||
|
console.log('[hyprcrd] port:', serverPort);
|
||||||
|
|
||||||
|
phpProcess = spawn(phpBin, [
|
||||||
|
'-S', `127.0.0.1:${serverPort}`,
|
||||||
|
'-t', docRoot,
|
||||||
|
routerFile,
|
||||||
|
], {
|
||||||
|
cwd: resolvePath('.'),
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, PHP_CLI_SERVER_WORKERS: '2', HYPRCRD_DATA_DIR: dataDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
phpProcess.stderr.on('data', (d) => {
|
||||||
|
const line = d.toString().trim();
|
||||||
|
if (line && !line.includes('Accepted') && !line.includes('Closing'))
|
||||||
|
console.log('[php]', line);
|
||||||
|
});
|
||||||
|
|
||||||
|
phpProcess.on('exit', (code) => {
|
||||||
|
console.log(`[php] exited with code ${code}`);
|
||||||
|
phpProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the server to accept connections
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const ok = await new Promise(resolve => {
|
||||||
|
const sock = net.createConnection(serverPort, '127.0.0.1');
|
||||||
|
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
||||||
|
sock.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
if (ok) return serverPort;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.showErrorBox('Server failed', 'PHP server did not start in time.');
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 1150,
|
||||||
|
minHeight: 500,
|
||||||
|
title: 'hyprcrd',
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadURL(`http://127.0.0.1:${serverPort}/`);
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-fail-load', (e, code, desc) => {
|
||||||
|
console.log(`[hyprcrd] load failed: ${code} ${desc}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http://127.0.0.1')) return { action: 'allow' };
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('before-input-event', (e, input) => {
|
||||||
|
if (input.key === 'F12') mainWindow.webContents.toggleDevTools();
|
||||||
|
if (input.key === 'r' && (input.control || input.meta)) mainWindow.webContents.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => { mainWindow = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('ready', async () => {
|
||||||
|
const port = await startPhpServer();
|
||||||
|
if (port) createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => { app.quit(); });
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
if (phpProcess) {
|
||||||
|
phpProcess.kill('SIGTERM');
|
||||||
|
phpProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (!mainWindow && serverPort) createWindow();
|
||||||
|
});
|
||||||
Generated
+4896
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "hyprcrd",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dist": "electron-builder"
|
||||||
|
},
|
||||||
|
"author": "th",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^41.2.0",
|
||||||
|
"electron-builder": "^26.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.hyprcrd.app",
|
||||||
|
"productName": "hyprcrd",
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "router.php",
|
||||||
|
"to": "router.php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../public",
|
||||||
|
"to": "public",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../src",
|
||||||
|
"to": "src",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../config.php",
|
||||||
|
"to": "config.php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../data",
|
||||||
|
"to": "data",
|
||||||
|
"filter": [
|
||||||
|
".gitkeep"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"category": "public.app-category.productivity",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/darwin",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/win32",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"category": "Office",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/linux",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
linux/php
|
||||||
|
darwin/php
|
||||||
|
win32/php.exe
|
||||||
|
win32/*.dll
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Bundled PHP binaries
|
||||||
|
|
||||||
|
Place a static PHP binary for each target platform here. The Electron
|
||||||
|
app will use the bundled binary instead of requiring a system-wide PHP
|
||||||
|
installation.
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
Get static builds from https://github.com/crazywhalecc/static-php-cli/releases
|
||||||
|
(look for `php-8.3-*-cli` assets). Required extensions: pdo_sqlite, zip, session.
|
||||||
|
|
||||||
|
linux/php — x86_64 Linux ELF binary
|
||||||
|
darwin/php — macOS universal binary (arm64 + x86_64)
|
||||||
|
win32/php.exe — Windows PE binary
|
||||||
|
|
||||||
|
## Quick fetch (Linux example)
|
||||||
|
|
||||||
|
curl -L -o linux/php \
|
||||||
|
"https://dl.static-php.dev/static-php-cli/common/php-8.3.23-cli-linux-x86_64.tar.gz" \
|
||||||
|
&& tar -xzf linux/php -C linux/ && rm linux/php.tar.gz
|
||||||
|
|
||||||
|
Make sure the binary is executable: `chmod +x linux/php`
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { contextBridge } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('hyprcrdElectron', {
|
||||||
|
platform: process.platform,
|
||||||
|
isElectron: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// Router for PHP's built-in server (replaces .htaccess rewrite rules).
|
||||||
|
// Static files are served directly; everything else goes to index.php.
|
||||||
|
|
||||||
|
// docRoot is set via `php -S ... -t <docRoot>` in main.js
|
||||||
|
$docRoot = $_SERVER['DOCUMENT_ROOT'];
|
||||||
|
|
||||||
|
// Electron passes HYPRCRD_DATA_DIR so the app stores all user data
|
||||||
|
// outside the app bundle (survives updates).
|
||||||
|
$dataDir = getenv('HYPRCRD_DATA_DIR');
|
||||||
|
if ($dataDir) {
|
||||||
|
define('DB_PATH', $dataDir . '/hyprcrd.db');
|
||||||
|
define('UPLOAD_DIR', $dataDir . '/uploads');
|
||||||
|
if (!is_dir($dataDir . '/uploads')) {
|
||||||
|
mkdir($dataDir . '/uploads', 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$file = $docRoot . $path;
|
||||||
|
if ($path !== '/' && is_file($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve uploaded files from the external data dir
|
||||||
|
if ($dataDir && str_starts_with($path, '/uploads/')) {
|
||||||
|
$extFile = $dataDir . $path;
|
||||||
|
if (is_file($extFile)) {
|
||||||
|
$mime = mime_content_type($extFile) ?: 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
readfile($extFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require $docRoot . '/index.php';
|
||||||
+253
-18
@@ -26,7 +26,12 @@ body.no-scanlines::after { display: none !important; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
#main-header { box-shadow: var(--ui-shadow-header); }
|
#main-header { box-shadow: var(--ui-shadow-header); }
|
||||||
#main-footer { box-shadow: var(--ui-shadow-footer); }
|
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
|
||||||
|
#footer-count { flex-shrink: 0; }
|
||||||
|
@media (min-width: 1250px) {
|
||||||
|
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
|
||||||
|
}
|
||||||
|
html { min-width: 1150px; }
|
||||||
|
|
||||||
/* Cards: geometry & shadow driven by design tokens */
|
/* Cards: geometry & shadow driven by design tokens */
|
||||||
.board-card {
|
.board-card {
|
||||||
@@ -107,6 +112,29 @@ body.anim-slow *, body.anim-slow *::before, body.anim-slow *::after {
|
|||||||
transition-duration: 400ms !important;
|
transition-duration: 400ms !important;
|
||||||
animation-duration: 600ms !important;
|
animation-duration: 600ms !important;
|
||||||
}
|
}
|
||||||
|
/* Blink / pulse animations are status indicators with deliberate cadences
|
||||||
|
(cursor, overdue bar, unread chat button, new collab bucket 3-pulse).
|
||||||
|
Speeding them up makes them useless strobes, so they ignore the global
|
||||||
|
anim-* override via higher-specificity rules. */
|
||||||
|
body.anim-fast .cursor-blink::after,
|
||||||
|
body.anim-slow .cursor-blink::after {
|
||||||
|
animation: blink 1s step-end infinite !important;
|
||||||
|
animation-duration: 1s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .card-bar-pulse,
|
||||||
|
body.anim-slow .card-bar-pulse {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite !important;
|
||||||
|
animation-duration: 2s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger,
|
||||||
|
body.anim-slow .board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger {
|
||||||
|
animation-duration: 2s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .card-spinner,
|
||||||
|
body.anim-slow .card-spinner {
|
||||||
|
animation: spin 0.8s linear infinite !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Theme preview (settings) ── */
|
/* ── Theme preview (settings) ── */
|
||||||
.theme-preview {
|
.theme-preview {
|
||||||
@@ -261,13 +289,61 @@ body::after {
|
|||||||
.line-clamp-5 { -webkit-line-clamp: 5; }
|
.line-clamp-5 { -webkit-line-clamp: 5; }
|
||||||
|
|
||||||
/* Superview */
|
/* Superview */
|
||||||
.superview-grid { column-gap: 12px; padding: 1rem 1.5rem 6rem; }
|
/* Single-column list view (toggled via #superview-toggle). Each row is
|
||||||
.superview-item { padding: 3px 0; display: flex; align-items: center; gap: 6px; cursor: pointer; break-inside: avoid; transition: color 0.1s; }
|
a flex line with: color dot · optional icon · title (flex-grows) ·
|
||||||
.superview-item:hover { color: var(--t-bright); }
|
tag chips · bucket · date. Title ellipsizes when narrow. */
|
||||||
.superview-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
.superview-grid { padding: 1rem 1.5rem 6rem; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 1px; }
|
||||||
.superview-title { font-size: 11px; color: var(--t-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.superview-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--t-border) 50%, transparent);
|
||||||
|
}
|
||||||
|
.superview-item:hover { background: color-mix(in srgb, var(--t-raised) 60%, transparent); color: var(--t-bright); }
|
||||||
|
.superview-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.superview-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
.superview-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--t-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.superview-item:hover .superview-title { color: var(--t-bright); }
|
.superview-item:hover .superview-title { color: var(--t-bright); }
|
||||||
.superview-icon { font-size: 10px; flex-shrink: 0; }
|
.superview-tags { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
.superview-tag {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--ui-radius-tag, 2px);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.superview-tag-more { color: var(--t-dim); background: transparent; }
|
||||||
|
.superview-bucket {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.superview-bucket i { font-size: 12px; }
|
||||||
|
.superview-date {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.board-card.preview-active { border-color: var(--t-accent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 12px -2px color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
.board-card.preview-active { border-color: var(--t-accent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 12px -2px color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||||
|
|
||||||
@@ -386,6 +462,11 @@ body::after {
|
|||||||
}
|
}
|
||||||
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||||
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
||||||
|
.card-focused {
|
||||||
|
outline: 2px solid var(--t-accent2) !important;
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: 0 0 0 1px var(--t-accent2), 0 0 16px -4px color-mix(in srgb, var(--t-accent2) 40%, transparent) !important;
|
||||||
|
}
|
||||||
.card-select-check {
|
.card-select-check {
|
||||||
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
||||||
border-radius: 50%; background: var(--t-accent); z-index: 6;
|
border-radius: 50%; background: var(--t-accent); z-index: 6;
|
||||||
@@ -398,26 +479,38 @@ body::after {
|
|||||||
transform: rotate(45deg); margin-top: -2px;
|
transform: rotate(45deg); margin-top: -2px;
|
||||||
}
|
}
|
||||||
.card-dragging { opacity: 0.15; transform: scale(0.95); }
|
.card-dragging { opacity: 0.15; transform: scale(0.95); }
|
||||||
|
@keyframes card-remove {
|
||||||
|
0% { opacity:1; transform:scale(1); }
|
||||||
|
100% { opacity:0; transform:scale(0.85) translateY(8px); }
|
||||||
|
}
|
||||||
|
.card-removing {
|
||||||
|
animation: card-remove 0.25s ease-in forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.box-select {
|
.box-select {
|
||||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||||
pointer-events: none; z-index: 10;
|
pointer-events: none; z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop indicators — thick glowing bar */
|
/* Card drop cursor — single absolutely-positioned vertical line that
|
||||||
.card-drop-before::before,
|
lives inside #board (which is `position: relative`) and gets
|
||||||
.card-drop-after::after {
|
repositioned by the dragover handlers in `board.js`. Replaces the
|
||||||
content: '';
|
old `.card-drop-before::before` / `.card-drop-after::after` pseudo-
|
||||||
|
elements so the indicator is a real DOM node in the gap between cards
|
||||||
|
instead of a sliver glued to one card. The "before first card"
|
||||||
|
case works automatically: the first card's left-25% zone positions
|
||||||
|
the cursor at `card.offsetLeft - 6`, which sits in #board's left
|
||||||
|
padding and stays visible. */
|
||||||
|
#card-drop-cursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 4px;
|
width: 4px;
|
||||||
background: var(--t-accent);
|
background: var(--t-accent);
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.card-drop-before::before { left: -6px; }
|
|
||||||
.card-drop-after::after { right: -6px; }
|
|
||||||
|
|
||||||
/* Group fan preview */
|
/* Group fan preview */
|
||||||
.group-fan {
|
.group-fan {
|
||||||
@@ -642,8 +735,149 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.bucket-add.drag-over i,
|
.bucket-add.drag-over i,
|
||||||
.bucket-add.drag-over span { color: inherit !important; }
|
.bucket-add.drag-over span { color: inherit !important; }
|
||||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
/* Bucket reorder drop cursor — a single absolutely-positioned vertical
|
||||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
line that lives inside #bucket-bar and gets repositioned by the
|
||||||
|
dragover handler in `public/js/modules/buckets.js`. Showing the cursor
|
||||||
|
*between* tiles makes the insert point unambiguous next to the
|
||||||
|
super-bucket nest gesture (center 50% → nest, edges 25% → reorder). */
|
||||||
|
#bucket-drop-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--t-accent);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Drop-into-center highlight: target tile gets an outline that says
|
||||||
|
"release here to nest". Distinct from the insertion cursor so the
|
||||||
|
user can tell reorder vs. nest at a glance. */
|
||||||
|
.bucket-tab.bucket-nest-target {
|
||||||
|
outline: 2px dashed var(--t-accent) !important;
|
||||||
|
outline-offset: 1px;
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
|
||||||
|
arrive while the user is viewing a different bucket. Cleared the
|
||||||
|
moment the user navigates back to all (renderBoard drains the
|
||||||
|
unseen set + pulses the cards). The dot sits *inside* the tab's
|
||||||
|
top-right corner because the parent #main-header has overflow:
|
||||||
|
hidden — anything positioned outside the tab gets clipped twice
|
||||||
|
(overflow-x-auto on bucket-bar + overflow:hidden on header). */
|
||||||
|
.bucket-tab.has-unread { position: relative; }
|
||||||
|
.bar-unread-dot {
|
||||||
|
position: absolute; top: 3px; right: 3px;
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 75%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Look at me" pulse on cards that just arrived. Triggered by
|
||||||
|
board.js when the all view is shown with unseen cards in the set —
|
||||||
|
either because new cards arrived while the user was already on
|
||||||
|
all, or because the user just navigated to all and the unseen ids
|
||||||
|
accumulated since the last visit. 3 short accent flashes, then
|
||||||
|
the class is stripped automatically by setTimeout. */
|
||||||
|
@keyframes card-pulse-new {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: var(--ui-shadow-card, none),
|
||||||
|
0 0 0 0 color-mix(in srgb, var(--t-accent) 80%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: var(--ui-shadow-card, none),
|
||||||
|
0 0 18px 4px color-mix(in srgb, var(--t-accent) 75%, transparent),
|
||||||
|
inset 0 0 0 2px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.board-card.card-pulse-new {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 3;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
/* Single pulse for cards the user just created themselves — subtler
|
||||||
|
than the triple-pulse for relay-synced cards so the user registers
|
||||||
|
"I made this" without the urgency of "someone else sent this". */
|
||||||
|
.board-card.card-pulse-local {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 1;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
|
||||||
|
body.anim-fast .board-card.card-pulse-new,
|
||||||
|
body.anim-slow .board-card.card-pulse-new {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 3 !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .board-card.card-pulse-local,
|
||||||
|
body.anim-slow .board-card.card-pulse-local {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 1 !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||||
|
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||||
|
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
||||||
|
#bucket-super-popover {
|
||||||
|
z-index: 220;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal, 0 10px 30px rgba(0,0,0,0.4));
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shortcut help table — table-fixed prevents column wobble when content
|
||||||
|
length varies between sections. kbd styling matches typical keyboard-cap
|
||||||
|
look (surface bg, border, mono font, tiny padding). */
|
||||||
|
/* Asset tab filters in the gallery/assets overlay header */
|
||||||
|
.gallery-tab { border-radius: var(--ui-radius-button, 0); }
|
||||||
|
.gallery-tab:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.gallery-tab.active { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.shortcut-table { width:100%; table-layout:fixed; font-size:12px; border-collapse:collapse; }
|
||||||
|
.shortcut-table td { padding:4px 0; vertical-align:middle; color:var(--t-text); }
|
||||||
|
.shortcut-table td:first-child { width:48%; color:var(--t-dim); white-space:nowrap; font-size:11px; }
|
||||||
|
/* Keycap look: 3D-ish raised surface with bottom shadow simulating
|
||||||
|
depth, subtle gradient, rounded corners. Min-width so single-letter
|
||||||
|
keys don't look squished. The "+" separator between combo keys sits
|
||||||
|
outside <kbd> and inherits the dim color from the td. */
|
||||||
|
.shortcut-table kbd {
|
||||||
|
display:inline-block;
|
||||||
|
min-width:20px; text-align:center;
|
||||||
|
padding:3px 7px;
|
||||||
|
background: linear-gradient(180deg, color-mix(in srgb, var(--t-raised) 90%, white 10%), var(--t-bg));
|
||||||
|
border:1px solid var(--t-border);
|
||||||
|
border-bottom-width:2px;
|
||||||
|
border-radius:4px;
|
||||||
|
font-family:var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size:10px;
|
||||||
|
color:var(--t-bright);
|
||||||
|
line-height:1.4;
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, var(--t-border) 80%, black 20%),
|
||||||
|
inset 0 1px 0 color-mix(in srgb, var(--t-raised) 60%, white 15%);
|
||||||
|
}
|
||||||
|
.shortcut-table .mouse {
|
||||||
|
font-family:var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size:9px; color:var(--t-dim);
|
||||||
|
}
|
||||||
|
/* The auto-generated shortcut help uses a 3-column grid on the
|
||||||
|
.shortcut-help-content div. Collapse to 2 then 1 on smaller screens. */
|
||||||
|
.shortcut-help-content { column-gap: 20px; }
|
||||||
|
.shortcut-help-content section { break-inside: avoid; margin-bottom: 16px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shortcut-help-content { grid-template-columns: 1fr 1fr !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.shortcut-help-content { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag chips */
|
/* Tag chips */
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
@@ -678,6 +912,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
.search-hit { scroll-margin: 8px; }
|
.search-hit { scroll-margin: 8px; }
|
||||||
.search-hit.active { background: var(--t-raised); }
|
.search-hit.active { background: var(--t-raised); }
|
||||||
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
||||||
|
#search-replace-toggle.active { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
||||||
|
|||||||
+198
-38
@@ -341,27 +341,75 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$name = trim($input['name'] ?? '');
|
$name = trim($input['name'] ?? '');
|
||||||
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
|
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
|
||||||
|
// Normal POST creates a local bucket. Collab buckets go through
|
||||||
|
// /api/collab-buckets (plugin-owned) and can't be created here.
|
||||||
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
|
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
|
||||||
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
|
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
// Collab buckets: name and color must go through the relay plugin so
|
||||||
|
// all members see the update. Only position (local reorder) is allowed.
|
||||||
|
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
|
||||||
|
$b->execute(['id' => $m[1]]);
|
||||||
|
$existing = $b->fetch();
|
||||||
|
$isCollab = $existing && !empty($existing['collab_uid']);
|
||||||
|
$allowed = $isCollab ? ['position'] : ['name', 'position', 'color', 'parent_id'];
|
||||||
|
// Super-bucket depth + cycle enforcement: a bucket may only become a
|
||||||
|
// child if (a) it has no children itself, (b) the prospective parent
|
||||||
|
// is top-level (parent_id IS NULL), and (c) self-parent is forbidden.
|
||||||
|
if (!$isCollab && array_key_exists('parent_id', $input)) {
|
||||||
|
$newParent = $input['parent_id'];
|
||||||
|
if ($newParent !== null && (int)$newParent !== 0) {
|
||||||
|
$newParent = (int)$newParent;
|
||||||
|
if ($newParent === (int)$m[1]) {
|
||||||
|
http_response_code(400); echo json_encode(['error' => 'cannot parent to self']); exit;
|
||||||
|
}
|
||||||
|
$h = $db->prepare('SELECT COUNT(*) FROM buckets WHERE parent_id = :id');
|
||||||
|
$h->execute(['id' => $m[1]]);
|
||||||
|
if ((int)$h->fetchColumn() > 0) {
|
||||||
|
http_response_code(400); echo json_encode(['error' => 'this bucket has children — flatten it first']); exit;
|
||||||
|
}
|
||||||
|
$p = $db->prepare('SELECT parent_id FROM buckets WHERE id = :id');
|
||||||
|
$p->execute(['id' => $newParent]);
|
||||||
|
$parentRow = $p->fetch();
|
||||||
|
if (!$parentRow) { http_response_code(400); echo json_encode(['error' => 'parent not found']); exit; }
|
||||||
|
if ($parentRow['parent_id'] !== null) {
|
||||||
|
http_response_code(400); echo json_encode(['error' => 'cannot nest under a child bucket']); exit;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$input['parent_id'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
$fields = []; $params = ['id' => $m[1]];
|
$fields = []; $params = ['id' => $m[1]];
|
||||||
foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
|
foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
|
||||||
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
|
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
// Collab buckets must go through /api/collab-buckets/{uid} so the relay
|
||||||
|
// and plugin mirror stay consistent. Refuse deletion here.
|
||||||
|
$b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
|
||||||
|
$b->execute(['id' => $m[1]]);
|
||||||
|
$existing = $b->fetch();
|
||||||
|
if ($existing && !empty($existing['collab_uid'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
|
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
|
||||||
|
// If this was a super-bucket parent, un-nest its children back to top-level
|
||||||
|
// instead of cascade-deleting them.
|
||||||
|
$db->prepare('UPDATE buckets SET parent_id = NULL WHERE parent_id = :id')->execute(['id' => $m[1]]);
|
||||||
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
|
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all upload files
|
// List all upload files
|
||||||
if ($route === '/api/uploads' && $method === 'GET') {
|
if ($route === '/api/uploads' && $method === 'GET') {
|
||||||
$dir = __DIR__ . '/uploads';
|
$dir = UPLOAD_DIR;
|
||||||
$files = [];
|
$files = [];
|
||||||
if (is_dir($dir)) {
|
if (is_dir($dir)) {
|
||||||
foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
|
foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
|
||||||
@@ -376,7 +424,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
// Delete an upload file
|
// Delete an upload file
|
||||||
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
|
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
$file = __DIR__ . '/uploads/' . basename($m[1]);
|
$file = UPLOAD_DIR . '/' . basename($m[1]);
|
||||||
if (is_file($file)) {
|
if (is_file($file)) {
|
||||||
unlink($file);
|
unlink($file);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
@@ -389,7 +437,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
// Upload (max 10MB, real MIME check)
|
// Upload (max 10MB, real MIME check)
|
||||||
if ($route === '/api/upload' && $method === 'POST') {
|
if ($route === '/api/upload' && $method === 'POST') {
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||||
|
|
||||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
@@ -406,8 +454,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check MIME — finfo first, fallback to extension for audio
|
// Check MIME — finfo first, fallback to extension for audio
|
||||||
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg'];
|
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf'];
|
||||||
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a'];
|
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf'];
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
$realMime = $finfo->file($file['tmp_name']);
|
$realMime = $finfo->file($file['tmp_name']);
|
||||||
$ext = $mimeMap[$realMime] ?? null;
|
$ext = $mimeMap[$realMime] ?? null;
|
||||||
@@ -512,7 +560,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy referenced uploads → attachments/
|
// Copy referenced uploads → attachments/
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (is_dir($uploadDir)) {
|
if (is_dir($uploadDir)) {
|
||||||
foreach (glob($uploadDir . '/*') as $f) {
|
foreach (glob($uploadDir . '/*') as $f) {
|
||||||
$name = basename($f);
|
$name = basename($f);
|
||||||
@@ -561,7 +609,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
// Only include referenced uploads
|
// Only include referenced uploads
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
$allBodies = implode("\n", array_column($cards, 'body'));
|
$allBodies = implode("\n", array_column($cards, 'body'));
|
||||||
if (is_dir($uploadDir)) {
|
if (is_dir($uploadDir)) {
|
||||||
foreach (glob($uploadDir . '/*') as $f) {
|
foreach (glob($uploadDir . '/*') as $f) {
|
||||||
@@ -625,10 +673,24 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$found = $existing->fetchColumn();
|
$found = $existing->fetchColumn();
|
||||||
if ($found) { $bucketMap[$oldId] = $found; continue; }
|
if ($found) { $bucketMap[$oldId] = $found; continue; }
|
||||||
}
|
}
|
||||||
$stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)');
|
$stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)');
|
||||||
$stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]);
|
$stmt->execute([
|
||||||
|
'name' => $b['name'],
|
||||||
|
'position' => $b['position'] ?? 0,
|
||||||
|
'color' => $b['color'] ?? '#64748b',
|
||||||
|
'collab_uid' => $b['collab_uid'] ?? null,
|
||||||
|
'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
$bucketMap[$oldId] = $db->lastInsertId();
|
$bucketMap[$oldId] = $db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
// Second pass: resolve parent_id (old id → new id) now that all
|
||||||
|
// buckets have been inserted and the map is complete.
|
||||||
|
foreach ($data['buckets'] as $b) {
|
||||||
|
if (!empty($b['parent_id']) && isset($bucketMap[$b['id']]) && isset($bucketMap[$b['parent_id']])) {
|
||||||
|
$db->prepare('UPDATE buckets SET parent_id = :p WHERE id = :id')
|
||||||
|
->execute(['p' => $bucketMap[$b['parent_id']], 'id' => $bucketMap[$b['id']]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Import cards (preserve all fields including CalDAV sync state)
|
// Import cards (preserve all fields including CalDAV sync state)
|
||||||
$cardMap = []; // old id → new id
|
$cardMap = []; // old id → new id
|
||||||
@@ -657,7 +719,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract uploads
|
// Extract uploads
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$name = $zip->getNameIndex($i);
|
$name = $zip->getNameIndex($i);
|
||||||
@@ -820,7 +882,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
if ($attStatus === 200) {
|
if ($attStatus === 200) {
|
||||||
$attachments = json_decode($attResponse, true);
|
$attachments = json_decode($attResponse, true);
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||||
foreach ($attachments ?? [] as $att) {
|
foreach ($attachments ?? [] as $att) {
|
||||||
$fn = basename($att['filename']);
|
$fn = basename($att['filename']);
|
||||||
@@ -947,7 +1009,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
// the sender's secret and re-encrypt with the recipient's secret.
|
// the sender's secret and re-encrypt with the recipient's secret.
|
||||||
$cardBody = $card['body'] ?? '';
|
$cardBody = $card['body'] ?? '';
|
||||||
preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches);
|
preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches);
|
||||||
$uploadDir = __DIR__ . '/uploads';
|
$uploadDir = UPLOAD_DIR;
|
||||||
foreach (array_unique($attMatches[1] ?? []) as $filename) {
|
foreach (array_unique($attMatches[1] ?? []) as $filename) {
|
||||||
$filePath = $uploadDir . '/' . $filename;
|
$filePath = $uploadDir . '/' . $filename;
|
||||||
if (!file_exists($filePath)) continue;
|
if (!file_exists($filePath)) continue;
|
||||||
@@ -1160,7 +1222,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
|
<i class="ph ph-terminal-window text-t-accent text-xl"></i>
|
||||||
<h1 class="brand font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
|
<h1 class="brand font-pixel text-t-accent text-lg tracking-wider">hyprcrd</h1>
|
||||||
<span class="text-[10px] text-t-dim font-pixel ml-1">v<?= APP_VERSION ?></span>
|
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
|
||||||
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
|
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
|
||||||
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
|
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
|
||||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
|
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
|
||||||
@@ -1174,7 +1236,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
|
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">Images</span>
|
<i class="ph ph-folder-open text-sm"></i><span class="font-pixel text-[10px]">Assets</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
|
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
|
||||||
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span>
|
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span>
|
||||||
@@ -1193,7 +1255,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||||
<div class="flex items-center gap-1 overflow-x-auto flex-1" id="bucket-bar"></div>
|
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
|
||||||
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
|
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
|
||||||
<i class="ph ph-split-horizontal text-sm"></i>
|
<i class="ph ph-split-horizontal text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -1229,7 +1291,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div id="board-preview-empty" class="flex-1 flex flex-col items-center justify-center text-t-dim">
|
<div id="board-preview-empty" class="flex-1 flex flex-col items-center justify-center text-t-dim">
|
||||||
<i class="ph ph-article text-3xl mb-2"></i>
|
<i class="ph ph-article text-3xl mb-2"></i>
|
||||||
<p class="font-pixel text-[10px]">click a card to preview</p>
|
<p class="font-pixel text-[10px] cursor-blink">click a card to preview</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="board-preview-content" class="hidden flex-1 flex flex-col min-h-0">
|
<div id="board-preview-content" class="hidden flex-1 flex flex-col min-h-0">
|
||||||
<div id="board-preview-body" class="flex-1 overflow-y-auto p-5 prose text-sm text-t-text"></div>
|
<div id="board-preview-body" class="flex-1 overflow-y-auto p-5 prose text-sm text-t-text"></div>
|
||||||
@@ -1258,12 +1320,29 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Settings</span>
|
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
|
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap"></div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="shortcuts-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-keyboard text-sm"></i><span class="font-pixel text-[10px]">Keys</span></button>
|
||||||
<button id="help-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-question text-sm"></i><span class="font-pixel text-[10px]">Help</span></button>
|
<button id="help-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-question text-sm"></i><span class="font-pixel text-[10px]">Help</span></button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- Shortcuts (?) -->
|
||||||
|
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="shortcuts">
|
||||||
|
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-4xl max-h-full overflow-y-auto">
|
||||||
|
<div class="flex items-center gap-3 px-6 py-4 border-b border-t-border sticky top-0 bg-t-surface z-[1]">
|
||||||
|
<i class="ph ph-keyboard text-t-accent text-base"></i>
|
||||||
|
<h2 class="font-pixel text-sm text-t-accent">keyboard shortcuts</h2>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="shortcuts-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
|
</div>
|
||||||
|
<!-- Filled by shortcuts.js renderShortcutHelp() from the registry.
|
||||||
|
Layout: 3 columns on desktop, 2 on tablet, 1 on mobile.
|
||||||
|
The CSS grid is set on .shortcut-help-content. -->
|
||||||
|
<div class="shortcut-help-content p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Help -->
|
<!-- Help -->
|
||||||
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="help">
|
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="help">
|
||||||
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-3xl max-h-full overflow-y-auto">
|
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-3xl max-h-full overflow-y-auto">
|
||||||
@@ -1279,7 +1358,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-info text-sm"></i>concepts</h3>
|
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-info text-sm"></i>concepts</h3>
|
||||||
<div class="text-xs leading-relaxed space-y-3 text-t-muted">
|
<div class="text-xs leading-relaxed space-y-3 text-t-muted">
|
||||||
<p><i class="ph ph-cards text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">cards</span> are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.</p>
|
<p><i class="ph ph-cards text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">cards</span> are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.</p>
|
||||||
<p><i class="ph ph-folder text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">buckets</span> organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: <i class="ph ph-squares-four text-xs"></i> <span class="text-t-dim font-pixel">all</span>, <i class="ph ph-lightning text-xs"></i> <span class="text-t-dim font-pixel">current</span>, <i class="ph ph-star text-xs"></i> <span class="text-t-dim font-pixel">favorites</span>, <i class="ph ph-notebook text-xs"></i> <span class="text-t-dim font-pixel">journal</span>, <i class="ph ph-tray text-xs"></i> <span class="text-t-dim font-pixel">unsorted</span>. Drag a card onto a bucket tab to move it. Drag to <i class="ph ph-plus text-xs"></i> to create a new bucket.</p>
|
<p><i class="ph ph-folder text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">buckets</span> organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: <i class="ph ph-squares-four text-xs"></i> <span class="text-t-dim font-pixel">all</span>, <i class="ph ph-lightning text-xs"></i> <span class="text-t-dim font-pixel">current</span>, <i class="ph ph-star text-xs"></i> <span class="text-t-dim font-pixel">favorites</span>, <i class="ph ph-notebook text-xs"></i> <span class="text-t-dim font-pixel">journal</span>, <i class="ph ph-tray text-xs"></i> <span class="text-t-dim font-pixel">unsorted</span>. Drag a card onto a bucket tab to move it. Drag to <i class="ph ph-plus text-xs"></i> to create a new bucket. Buckets can be nested one level deep — drag a bucket onto another to create a <span class="text-t-bright font-pixel">super-bucket</span> with a dropdown for its children.</p>
|
||||||
<p><i class="ph ph-stack text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">groups</span> let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use <i class="ph ph-stack text-xs"></i> "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use <i class="ph ph-arrow-line-up text-xs"></i> "dissolve group" to ungroup all at once.</p>
|
<p><i class="ph ph-stack text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">groups</span> let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use <i class="ph ph-stack text-xs"></i> "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use <i class="ph ph-arrow-line-up text-xs"></i> "dissolve group" to ungroup all at once.</p>
|
||||||
<p><i class="ph ph-hash text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">tags</span> are written as <span class="text-t-accent font-pixel">#tagname</span> in the card body and extracted automatically. They support hierarchy: <span class="text-t-accent font-pixel">#project/frontend</span>. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > <i class="ph ph-hash text-xs"></i> tags. Each segment of a hierarchical tag is independently clickable.</p>
|
<p><i class="ph ph-hash text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">tags</span> are written as <span class="text-t-accent font-pixel">#tagname</span> in the card body and extracted automatically. They support hierarchy: <span class="text-t-accent font-pixel">#project/frontend</span>. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > <i class="ph ph-hash text-xs"></i> tags. Each segment of a hierarchical tag is independently clickable.</p>
|
||||||
<p><i class="ph ph-link text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">links</span> connect cards. Write <span class="text-t-accent font-pixel">[[Card Title]]</span> in the body to create a link. Linked cards are clickable in preview and show up as <i class="ph ph-arrow-bend-left-up text-xs"></i> backlinks on the target card. The <i class="ph ph-split-horizontal text-xs"></i> split view shows related cards based on shared tags, links, and content overlap.</p>
|
<p><i class="ph ph-link text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">links</span> connect cards. Write <span class="text-t-accent font-pixel">[[Card Title]]</span> in the body to create a link. Linked cards are clickable in preview and show up as <i class="ph ph-arrow-bend-left-up text-xs"></i> backlinks on the target card. The <i class="ph ph-split-horizontal text-xs"></i> split view shows related cards based on shared tags, links, and content overlap.</p>
|
||||||
@@ -1289,13 +1368,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-keyboard text-sm"></i>shortcuts</h3>
|
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-keyboard text-sm"></i>shortcuts</h3>
|
||||||
<table class="w-full text-xs">
|
<p class="text-xs text-t-muted mb-2">Press <kbd class="px-1 py-0.5 border border-t-border rounded text-[9px] font-pixel">?</kbd> for the full keyboard reference.</p>
|
||||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+K</td><td><i class="ph ph-magnifying-glass text-xs"></i> open / close search</td></tr>
|
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+N</td><td><i class="ph ph-lightning text-xs"></i> quick capture — type title, Enter to save</td></tr>
|
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Z</td><td><i class="ph ph-arrow-counter-clockwise text-xs"></i> undo last action</td></tr>
|
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">F1</td><td><i class="ph ph-question text-xs"></i> open / close help</td></tr>
|
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Escape</td><td><i class="ph ph-x text-xs"></i> close any overlay</td></tr>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -1303,12 +1376,30 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">click card</td><td><i class="ph ph-eye text-xs"></i> open preview (or side panel in split mode)</td></tr>
|
<tr><td class="py-1 text-t-dim w-40 font-pixel">click card</td><td><i class="ph ph-eye text-xs"></i> open preview (or side panel in split mode)</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">double-click</td><td><i class="ph ph-pencil-simple text-xs"></i> open editor</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">double-click</td><td><i class="ph ph-pencil-simple text-xs"></i> open editor</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">right-click</td><td><i class="ph ph-list text-xs"></i> context menu (color, bucket, tags, ...)</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">right-click</td><td><i class="ph ph-list text-xs"></i> context menu (color, bucket, tags, copy text, mark done, join, ...)</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click</td><td><i class="ph ph-check-square text-xs"></i> multi-select cards</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click</td><td><i class="ph ph-check-square text-xs"></i> multi-select cards</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">drag card</td><td><i class="ph ph-arrows-out-cardinal text-xs"></i> reorder, move to bucket, or group</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">drag card</td><td><i class="ph ph-arrows-out-cardinal text-xs"></i> reorder, move to bucket, or group</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrow-up text-xs"></i><i class="ph ph-arrow-down text-xs"></i><i class="ph ph-arrow-left text-xs"></i><i class="ph ph-arrow-right text-xs"></i></td><td>navigate cards with arrow keys, Enter to open</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">click tag</td><td><i class="ph ph-funnel text-xs"></i> filter by tag</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">click tag</td><td><i class="ph ph-funnel text-xs"></i> filter by tag</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click tag</td><td><i class="ph ph-plus-circle text-xs"></i> add/remove tag filter (AND)</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click tag</td><td><i class="ph ph-plus-circle text-xs"></i> add/remove tag filter (AND)</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">group badge</td><td><i class="ph ph-stack text-xs"></i> expand group</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">group badge</td><td><i class="ph ph-stack text-xs"></i> expand group</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+N</td><td><i class="ph ph-lightning text-xs"></i> quick capture — create card in active bucket</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">1–9</td><td><i class="ph ph-folder text-xs"></i> switch to bucket by number</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-list text-sm"></i>context menu</h3>
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-palette text-xs"></i> color</td><td>change card color</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-folder text-xs"></i> move</td><td>move to a different bucket</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> tags</td><td>assign tags via picker</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> copy text</td><td>copy card body as plain text (strips markdown)</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> mark done</td><td>toggle #done tag on any card</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-unite text-xs"></i> join cards</td><td>merge multi-selected cards into one</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> duplicate</td><td>duplicate card</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-md text-xs"></i> save .md</td><td>download as markdown + images</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-trash text-xs"></i> delete</td><td>delete card (with undo)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1329,11 +1420,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save</td></tr>
|
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save and close</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save and close</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+H</td><td><i class="ph ph-magnifying-glass text-xs"></i> find + replace in card</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+F</td><td><i class="ph ph-magnifying-glass text-xs"></i> find in card</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>move line up/down</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>move line up/down</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Shift+Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>duplicate line</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Shift+Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>duplicate line</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">Tab</td><td>indent (4 spaces)</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">Tab</td><td>indent (4 spaces)</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">paste / drag</td><td><i class="ph ph-upload-simple text-xs"></i> upload and insert image</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">paste / drag</td><td><i class="ph ph-upload-simple text-xs"></i> upload image, audio, or PDF</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-scissors text-xs"></i> extract</td><td>selection to new card</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-scissors text-xs"></i> extract</td><td>selection to new card</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-pdf text-xs"></i> PDF</td><td>upload + link a PDF from toolbar</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">drag divider</td><td>resize editor / preview split</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">drag divider</td><td>resize editor / preview split</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -1391,6 +1485,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag</span> word</td><td>tag filter + text search combined</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag</span> word</td><td>tag filter + text search combined</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag1 #tag2</span></td><td><i class="ph ph-intersect text-xs"></i> multiple tags (AND)</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag1 #tag2</span></td><td><i class="ph ph-intersect text-xs"></i> multiple tags (AND)</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel">bucket filter</td><td><i class="ph ph-folder text-xs"></i> dropdown to restrict scope</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel">bucket filter</td><td><i class="ph ph-folder text-xs"></i> dropdown to restrict scope</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-swap text-xs"></i> Replace</td><td>toggle replace mode — bulk find + replace across all matching cards</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+H</td><td><i class="ph ph-swap text-xs"></i> open search directly in replace mode</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel">drag result</td><td><i class="ph ph-stack text-xs"></i> drag onto another card to group</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1433,12 +1530,30 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-layout text-sm"></i>views</h3>
|
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-layout text-sm"></i>views & toolbar</h3>
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-split-horizontal text-xs"></i> split</td><td>side-by-side board + preview panel</td></tr>
|
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-split-horizontal text-xs"></i> split</td><td>side-by-side board + preview panel</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list text-xs"></i> superview</td><td>compact multi-column title list with hover cards</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list text-xs"></i> superview</td><td>compact multi-column title list with hover cards</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> hide done</td><td>toggle cards tagged #done on/off</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-calendar-x text-xs"></i> hide termine</td><td>toggle appointment cards on/off</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-calendar-x text-xs"></i> hide termine</td><td>toggle appointment cards on/off</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> hide tagged</td><td>toggle tagged cards on/off</td></tr>
|
||||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-sort-ascending text-xs"></i> sort</td><td>manual / newest / oldest / alpha / tag</td></tr>
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-sort-ascending text-xs"></i> sort</td><td>manual / newest / oldest / alpha / tag</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-images text-xs"></i> assets</td><td>gallery of all images, audio, and PDFs across cards — with orphan detection</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-broadcast text-sm"></i>relay</h3>
|
||||||
|
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
|
||||||
|
<p>The <span class="text-t-bright font-pixel">relay</span> is an optional self-hosted server that connects multiple hyprcrd instances. Configure the relay URL and your client secret in <i class="ph ph-gear text-xs"></i> Settings > Relay. The relay has its own admin UI for user management.</p>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-folders text-xs"></i> collab-buckets</td><td>shared buckets between tenants — exclusive card locking, unread indicators, E2E encrypted sync</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-chat-circle-dots text-xs"></i> chat</td><td>encrypted chat between tenants — footer flyout, delivery + read receipts</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-paper-plane-tilt text-xs"></i> compose</td><td>send messages to other tenants via the relay inbox</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-tray text-xs"></i> inbox</td><td>read received messages — synced badge shows delivery status</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-lock text-xs"></i> card locking</td><td>editing a collab card locks it for all others — takeover on first keystroke</td></tr>
|
||||||
|
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-key text-xs"></i> client secret</td><td>per-tenant E2E key — set in relay account settings, shared across devices</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1453,7 +1568,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="pt-4 border-t border-t-border text-center">
|
<div class="pt-4 border-t border-t-border text-center">
|
||||||
<span class="font-pixel text-[10px] text-t-dim">© <?= date('Y') ?> hyprcrd · v<?= APP_VERSION ?></span>
|
<span class="font-pixel text-[10px] text-t-dim">© <?= date('Y') ?> hyprcrd · <span data-app-version>v<?= APP_VERSION ?></span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1484,7 +1599,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<i class="ph ph-terminal-window text-t-accent text-5xl mb-4"></i>
|
<i class="ph ph-terminal-window text-t-accent text-5xl mb-4"></i>
|
||||||
<h1 class="brand font-pixel text-2xl text-t-accent tracking-wider mb-2">hyprcrd</h1>
|
<h1 class="brand font-pixel text-2xl text-t-accent tracking-wider mb-2">hyprcrd</h1>
|
||||||
<p class="font-pixel text-[10px] text-t-dim mb-6">v<?= APP_VERSION ?></p>
|
<p class="font-pixel text-[10px] text-t-dim mb-6" data-app-version>v<?= APP_VERSION ?></p>
|
||||||
<p class="text-xs text-t-muted leading-relaxed max-w-sm">Self-hosted card manager with markdown, tags, calendar, CalDAV sync, encrypted WebDAV notes, and Obsidian-compatible export.</p>
|
<p class="text-xs text-t-muted leading-relaxed max-w-sm">Self-hosted card manager with markdown, tags, calendar, CalDAV sync, encrypted WebDAV notes, and Obsidian-compatible export.</p>
|
||||||
<div class="flex items-center gap-4 mt-8 text-t-dim">
|
<div class="flex items-center gap-4 mt-8 text-t-dim">
|
||||||
<div class="text-center"><div id="stats-cards" class="font-pixel text-lg text-t-bright">—</div><div class="font-pixel text-[8px] uppercase">cards</div></div>
|
<div class="text-center"><div id="stats-cards" class="font-pixel text-lg text-t-bright">—</div><div class="font-pixel text-[8px] uppercase">cards</div></div>
|
||||||
@@ -1815,18 +1930,31 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
<div class="flex flex-col border-b border-t-border">
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||||
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
||||||
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
||||||
|
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace">
|
||||||
|
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
||||||
|
</button>
|
||||||
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="search-replace-row" class="hidden flex items-center gap-3 px-4 pb-3">
|
||||||
|
<i class="ph ph-swap text-t-dim text-base"></i>
|
||||||
|
<input type="text" id="search-replace-input" placeholder="replace with..." class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1.5 font-mono focus:border-t-accent focus:outline-none rounded-sm" autocomplete="off" spellcheck="false">
|
||||||
|
<span id="search-replace-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<button id="search-replace-all" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors font-pixel text-[10px]" disabled>
|
||||||
|
<i class="ph ph-swap text-sm"></i> Replace all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
|
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
|
||||||
<div class="flex-1 overflow-y-auto p-5">
|
<div class="flex-1 overflow-y-auto p-5">
|
||||||
<div id="search-preview-empty" class="flex flex-col items-center justify-center h-full text-t-dim"><i class="ph ph-article text-3xl mb-2"></i><p class="font-pixel text-[10px]">select a card</p></div>
|
<div id="search-preview-empty" class="flex flex-col items-center justify-center h-full text-t-dim"><i class="ph ph-article text-3xl mb-2"></i><p class="font-pixel text-[10px] cursor-blink">select a card</p></div>
|
||||||
<div id="search-preview-content" class="hidden">
|
<div id="search-preview-content" class="hidden">
|
||||||
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
|
<div class="card-bar w-16 mb-4 rounded-sm" id="search-preview-color"></div>
|
||||||
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
|
<h2 id="search-preview-title" class="font-pixel text-t-bright text-sm mb-4 leading-relaxed"></h2>
|
||||||
@@ -1954,18 +2082,24 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Gallery -->
|
<!-- Assets (was Gallery) -->
|
||||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
||||||
<i class="ph ph-images text-t-accent text-base"></i>
|
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent">images</h2>
|
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
||||||
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
|
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<div id="gallery-tabs" class="flex items-center gap-1 ml-3">
|
||||||
|
<button class="gallery-tab active font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="all">All</button>
|
||||||
|
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="image">Images</button>
|
||||||
|
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="audio">Audio</button>
|
||||||
|
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="pdf">PDF</button>
|
||||||
|
</div>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span>
|
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="file" id="gallery-upload-file" accept="image/*,audio/*" multiple class="hidden">
|
<input type="file" id="gallery-upload-file" accept="image/*,audio/*,.pdf,application/pdf" multiple class="hidden">
|
||||||
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1980,6 +2114,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="select-group" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="select-group" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Group</span>
|
<i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Group</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="select-join" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
|
<i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join</span>
|
||||||
|
</button>
|
||||||
<button id="select-bucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="select-bucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span>
|
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2064,6 +2201,10 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||||
|
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
|
||||||
|
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
||||||
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
|
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
|
||||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||||
@@ -2071,10 +2212,29 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
||||||
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden" style="flex:1 1 0%;min-width:0;">
|
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden flex flex-col" style="flex:1 1 0%;min-width:0;">
|
||||||
|
<!-- Find / Replace bar (Ctrl+H) -->
|
||||||
|
<div id="editor-find-bar" class="hidden shrink-0 border-b border-t-border bg-t-surface px-3 py-2" style="z-index:5;">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
|
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
|
||||||
|
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||||
|
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
|
||||||
|
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button>
|
||||||
|
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button>
|
||||||
|
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="replace-row" class="flex items-center gap-2">
|
||||||
|
<i class="ph ph-swap text-xs text-t-dim"></i>
|
||||||
|
<input id="replace-input" type="text" placeholder="replace…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||||
|
<button id="replace-one" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Replace</button>
|
||||||
|
<button id="replace-all" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 min-h-0">
|
||||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+282
-48
@@ -6,13 +6,14 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
|
|||||||
import { state, getGroupCards } from './modules/state.js';
|
import { state, getGroupCards } from './modules/state.js';
|
||||||
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
||||||
import './modules/theme.js';
|
import './modules/theme.js';
|
||||||
import { loadBuckets, renderTagBar } from './modules/buckets.js';
|
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
||||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
||||||
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
|
import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './modules/editor.js';
|
||||||
import { openSearch, closeSearch } from './modules/search.js';
|
import { openSearch, closeSearch } from './modules/search.js';
|
||||||
import { showContextMenu } from './modules/context-menu.js';
|
import { showContextMenu } from './modules/context-menu.js';
|
||||||
import { undo, canUndo } from './modules/undo.js';
|
import { undo, canUndo } from './modules/undo.js';
|
||||||
|
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
|
||||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||||
@@ -38,7 +39,11 @@ window._ungroupCard = ungroupCard;
|
|||||||
window._getSelectedIds = getSelectedIds;
|
window._getSelectedIds = getSelectedIds;
|
||||||
window._clearSelection = clearSelection;
|
window._clearSelection = clearSelection;
|
||||||
window._refreshTasks = refreshTasksView;
|
window._refreshTasks = refreshTasksView;
|
||||||
|
window.openFindBar = openFindBar;
|
||||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||||
|
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
||||||
|
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||||
|
window.state = state;
|
||||||
window._extractToCard = async (text) => {
|
window._extractToCard = async (text) => {
|
||||||
if (!text?.trim()) return;
|
if (!text?.trim()) return;
|
||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
@@ -109,10 +114,13 @@ function closeHelp() { helpOverlay?.classList.remove('open'); }
|
|||||||
document.getElementById('help-btn')?.addEventListener('click', openHelp);
|
document.getElementById('help-btn')?.addEventListener('click', openHelp);
|
||||||
document.getElementById('help-close')?.addEventListener('click', closeHelp);
|
document.getElementById('help-close')?.addEventListener('click', closeHelp);
|
||||||
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
|
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const shortcutsOverlay = document.getElementById('shortcuts');
|
||||||
|
function openShortcuts() { shortcutsOverlay?.classList.add('open'); }
|
||||||
|
function closeShortcuts() { shortcutsOverlay?.classList.remove('open'); }
|
||||||
|
document.getElementById('shortcuts-btn')?.addEventListener('click', openShortcuts);
|
||||||
|
document.getElementById('shortcuts-close')?.addEventListener('click', closeShortcuts);
|
||||||
|
shortcutsOverlay?.addEventListener('click', e => { if (e.target === shortcutsOverlay) closeShortcuts(); });
|
||||||
// ── Export / Import ──
|
// ── Export / Import ──
|
||||||
|
|
||||||
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
||||||
@@ -155,6 +163,13 @@ async function checkAuth() {
|
|||||||
const res = await fetch('api/auth/status');
|
const res = await fetch('api/auth/status');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); }
|
if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); }
|
||||||
|
// Refresh version-displaying spans from the live API response so the
|
||||||
|
// GUI doesn't show a stale APP_VERSION when the PHP page is cached.
|
||||||
|
if (data.version) {
|
||||||
|
for (const el of document.querySelectorAll('[data-app-version]')) {
|
||||||
|
el.textContent = 'v' + data.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
let pw = null;
|
let pw = null;
|
||||||
@@ -183,56 +198,178 @@ async function checkAuth() {
|
|||||||
startBackgroundSync();
|
startBackgroundSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global keyboard ──
|
// ── Keyboard shortcuts (central registry) ──
|
||||||
|
//
|
||||||
|
// All document-level shortcuts are registered here via registerShortcut().
|
||||||
|
// The dispatcher in shortcuts.js handles key matching, guards, and
|
||||||
|
// priority ordering. Element-specific shortcuts (editor textarea, search
|
||||||
|
// input, canvas, dialog) stay on their respective DOM elements.
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
const { board: gBoard } = guards;
|
||||||
const modal = document.getElementById('modal');
|
|
||||||
const detail = document.getElementById('detail');
|
|
||||||
const search = document.getElementById('search');
|
|
||||||
const tasks = document.getElementById('tasks');
|
|
||||||
if (isDialogOpen()) return;
|
|
||||||
|
|
||||||
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
|
// — Escape chain (highest priority first) —
|
||||||
if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
|
||||||
e.preventDefault();
|
registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
|
||||||
const qc = document.getElementById('quick-capture');
|
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
|
||||||
const qci = document.getElementById('quick-capture-input');
|
registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
|
||||||
if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); }
|
registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
|
||||||
else { qc.classList.add('hidden'); qci.value = ''; }
|
registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() });
|
||||||
return;
|
registerShortcut({ key: 'Escape', priority: 40, guard: () => !isDialogOpen() && guards.isOpen('calendar'), section: 'global', label: 'close calendar', hidden: true, handler: () => closeCalendar() });
|
||||||
}
|
registerShortcut({ key: 'Escape', priority: 30, guard: () => !isDialogOpen() && guards.isOpen('shortcuts'), section: 'global', label: 'close shortcuts', hidden: true, handler: () => closeShortcuts() });
|
||||||
if (e.key === 'Escape') {
|
registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() });
|
||||||
const qc = document.getElementById('quick-capture');
|
// Visible Escape entry for the help screen
|
||||||
if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; }
|
registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: '<kbd>Esc</kbd>', label: 'close any overlay' });
|
||||||
if (detail.classList.contains('open')) {
|
|
||||||
// If the user chose "exit zen first", ESC leaves zen mode before closing the card.
|
|
||||||
const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close';
|
|
||||||
if (escAction === 'exitZen' && detail.classList.contains('zen')) {
|
|
||||||
document.getElementById('detail-zen')?.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeDetail();
|
|
||||||
}
|
|
||||||
else if (modal.classList.contains('open')) closeModal();
|
|
||||||
else if (search.classList.contains('open')) closeSearch();
|
|
||||||
else if (tasks?.classList.contains('open')) closeTasks();
|
|
||||||
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
|
|
||||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
|
|
||||||
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
|
|
||||||
|
|
||||||
// Arrow keys navigate group in detail view
|
// — Global (work everywhere unless dialog is open) —
|
||||||
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
|
registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>K</kbd>', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
|
||||||
|
registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>N</kbd>', label: 'quick capture (in bucket)', handler: () => { const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } } });
|
||||||
|
registerShortcut({ key: 'z', ctrl: true, guard: () => !isDialogOpen() && !guards.isOpen('modal') && canUndo(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>Z</kbd>', label: 'undo', handler: () => undo() });
|
||||||
|
registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>F1</kbd>', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
|
||||||
|
registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '<kbd>?</kbd>', label: 'this screen', handler: () => shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts() });
|
||||||
|
|
||||||
|
// — Editor (modal open) —
|
||||||
|
registerShortcut({ key: 's', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>S</kbd>', label: 'save', handler: () => saveOnly() });
|
||||||
|
registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd>', label: 'save + close', handler: () => saveCard() });
|
||||||
|
registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>↵</kbd>', label: 'save + close', handler: () => saveCard() });
|
||||||
|
// Editor element-level shortcuts listed for help screen only (handled by editor.js)
|
||||||
|
registerShortcut({ key: 'Tab', guard: () => false, section: 'editor', keys: '<kbd>⇥</kbd>', label: 'indent / title→body' });
|
||||||
|
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'move line' });
|
||||||
|
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'duplicate line' });
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<kbd>↵</kbd>', label: 'auto-continue list' });
|
||||||
|
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
|
||||||
|
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
|
||||||
|
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
|
||||||
|
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>H</kbd>', label: 'find + replace' });
|
||||||
|
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>F</kbd>', label: 'find' });
|
||||||
|
registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>H</kbd>', label: 'find + replace across cards', handler: () => openSearch({ replace: true }) });
|
||||||
|
|
||||||
|
// — Detail view —
|
||||||
|
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
|
||||||
const gc = getGroupCards(state.viewingCard);
|
const gc = getGroupCards(state.viewingCard);
|
||||||
if (!gc || gc.length < 2) return;
|
if (!gc || gc.length < 2) return;
|
||||||
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
|
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
e.preventDefault();
|
|
||||||
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
|
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
|
||||||
}
|
}});
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => !isDialogOpen() && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>↵</kbd> <span class="mouse">in preview</span>', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
|
||||||
|
|
||||||
|
// — Board navigation (body focus, no overlay) —
|
||||||
|
registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: '<kbd>A</kbd>', label: 'all cards', handler: () => activateBucket('all') });
|
||||||
|
registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: '<kbd>F</kbd>', label: 'favorites', handler: () => activateBucket('favorites') });
|
||||||
|
registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: '<kbd>N</kbd>', label: 'new card', handler: () => openModal(null) });
|
||||||
|
registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J</kbd>', label: 'today\'s journal', handler: () => {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
const existing = state.cards.find(c => (c.title || '').startsWith(today));
|
||||||
|
if (existing) openModal(existing);
|
||||||
|
else { activateBucket('journal'); openModal(null); }
|
||||||
|
}});
|
||||||
|
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
|
||||||
|
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
|
||||||
|
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
|
||||||
|
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
|
||||||
|
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => {
|
||||||
|
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
|
||||||
|
if (!tiles.length) return;
|
||||||
|
const curIdx = tiles.findIndex(t => {
|
||||||
|
const bid = t.dataset.bucket || t.dataset.bucketSuper;
|
||||||
|
if (bid == state.activeBucket) return true;
|
||||||
|
if (t.dataset.bucketSuper) return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket);
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
const dir = e.shiftKey ? -1 : 1;
|
||||||
|
const next = tiles[(curIdx + dir + tiles.length) % tiles.length];
|
||||||
|
if (next) activateBucket(next.dataset.bucketSuper ? (next.dataset.superShown || next.dataset.bucketSuper) : next.dataset.bucket);
|
||||||
|
}});
|
||||||
|
registerShortcut({ match: (e) => /^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && gBoard(), section: 'navigation', keys: '<kbd>1</kbd> – <kbd>9</kbd>', label: 'Nth user bucket', handler: (e) => {
|
||||||
|
const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||||
|
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')].filter(t => !SPECIAL.has(t.dataset.bucket));
|
||||||
|
const tile = tiles[parseInt(e.key, 10) - 1];
|
||||||
|
if (tile) activateBucket(tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// — Board arrow-key card navigation + Enter —
|
||||||
|
registerShortcut({ match: (e) => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && gBoard(), section: 'board + detail', keys: '<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd>', label: 'focus card', handler: (e) => {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
const cards = [...board.querySelectorAll('.board-card[data-id]')];
|
||||||
|
if (!cards.length) return;
|
||||||
|
const focused = board.querySelector('.card-focused');
|
||||||
|
let curIdx = focused ? cards.indexOf(focused) : -1;
|
||||||
|
if (curIdx < 0) { setCardFocus(cards[0]); return; }
|
||||||
|
const firstTop = cards[0].offsetTop;
|
||||||
|
let cols = cards.length;
|
||||||
|
for (let i = 1; i < cards.length; i++) { if (cards[i].offsetTop !== firstTop) { cols = i; break; } }
|
||||||
|
let next = curIdx;
|
||||||
|
if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1);
|
||||||
|
else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0);
|
||||||
|
else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1);
|
||||||
|
else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0);
|
||||||
|
if (next !== curIdx) setCardFocus(cards[next]);
|
||||||
|
}});
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelector('#board .card-focused'), section: 'board + detail', keys: '<kbd>↵</kbd>', label: 'focused → preview', handler: () => {
|
||||||
|
const focused = document.querySelector('#board .card-focused');
|
||||||
|
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
||||||
|
}});
|
||||||
|
// Delete selected cards
|
||||||
|
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
|
||||||
|
const ids = getSelectedIds();
|
||||||
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
|
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
const { animateCardRemoval } = await import('./modules/board.js');
|
||||||
|
await animateCardRemoval(ids);
|
||||||
|
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||||
|
clearSelection();
|
||||||
|
await window._reloadAll();
|
||||||
|
}});
|
||||||
|
|
||||||
|
// — Search (help-screen-only, handled by search.js element listener) —
|
||||||
|
registerShortcut({ key: 'ArrowDown', guard: () => false, section: 'search', keys: '<kbd>↑</kbd> <kbd>↓</kbd>', label: 'navigate results' });
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '<kbd>↵</kbd>', label: 'open selected result' });
|
||||||
|
registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: '<kbd>Esc</kbd>', label: 'close search' });
|
||||||
|
|
||||||
|
// — Board mouse actions (help-screen-only) —
|
||||||
|
registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: '<span class="mouse">click</span>', label: 'preview / split' });
|
||||||
|
registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: '<span class="mouse">dbl-click</span>', label: 'editor' });
|
||||||
|
registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: '<span class="mouse">right-click</span>', label: 'context menu' });
|
||||||
|
registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '<kbd>⌃</kbd> <span class="mouse">click</span>', label: 'multi-select' });
|
||||||
|
registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → edge</span>', label: 'reorder' });
|
||||||
|
registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → center</span>', label: 'group' });
|
||||||
|
|
||||||
|
// — Canvas (help-screen-only, handled by canvas.js overlay listener) —
|
||||||
|
for (const [k, lbl] of [['B','brush'],['E','eraser'],['L','line'],['R','rectangle'],['O','circle'],['F','fill'],['T','text cursor']]) {
|
||||||
|
registerShortcut({ key: k.toLowerCase(), guard: () => false, section: 'canvas', keys: `<kbd>${k}</kbd>`, label: lbl });
|
||||||
|
}
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '<kbd>⌫</kbd>', label: 'delete char' });
|
||||||
|
|
||||||
|
// — Mail (help-screen-only, handled by email.js document listener) —
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⌫</kbd>', label: 'delete selected mails' });
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⇧</kbd> <kbd>⌫</kbd>', label: 'delete (skip confirm)' });
|
||||||
|
|
||||||
|
let focusedCardId = null;
|
||||||
|
function setCardFocus(el) {
|
||||||
|
clearCardFocus();
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('card-focused');
|
||||||
|
focusedCardId = el.dataset.id;
|
||||||
|
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
function clearCardFocus() {
|
||||||
|
document.querySelectorAll('.card-focused').forEach(c => c.classList.remove('card-focused'));
|
||||||
|
focusedCardId = null;
|
||||||
|
}
|
||||||
|
// Clear focus when clicking anywhere on the board (a card click opens
|
||||||
|
// detail/editor and focus is no longer meaningful), or when any overlay
|
||||||
|
// opens. Also re-apply after a board re-render if the focused card is
|
||||||
|
// still in the DOM.
|
||||||
|
document.addEventListener('click', () => { if (focusedCardId) clearCardFocus(); });
|
||||||
|
window._restoreCardFocus = function () {
|
||||||
|
if (!focusedCardId) return;
|
||||||
|
const el = document.querySelector(`#board .board-card[data-id="${focusedCardId}"]`);
|
||||||
|
if (el) el.classList.add('card-focused');
|
||||||
|
else clearCardFocus();
|
||||||
|
};
|
||||||
|
|
||||||
// ── Quick Capture ──
|
// ── Quick Capture ──
|
||||||
|
|
||||||
@@ -261,9 +398,15 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
|||||||
body = lines.slice(1).join('\n').trim();
|
body = lines.slice(1).join('\n').trim();
|
||||||
}
|
}
|
||||||
if (!cardTitle) return;
|
if (!cardTitle) return;
|
||||||
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
|
// No auto-tag — the card goes into the active bucket (or unsorted).
|
||||||
|
|
||||||
await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1 } });
|
// Place the card in the currently active bucket (if it's a real
|
||||||
|
// user bucket). Special filters (all/current/favorites/journal/none)
|
||||||
|
// don't map to a bucket_id — cards captured there stay unsorted.
|
||||||
|
const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||||
|
const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket;
|
||||||
|
const captured = await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
|
||||||
|
if (captured?.id) state.recentlyCreatedIds?.add(String(captured.id));
|
||||||
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
document.getElementById('quick-capture').classList.add('hidden');
|
document.getElementById('quick-capture').classList.add('hidden');
|
||||||
@@ -277,8 +420,99 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
|||||||
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
|
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tag quick-pick (bare "t" hotkey on the board) ──
|
||||||
|
|
||||||
|
/** Show a small popover listing every tag found on any card. Typing
|
||||||
|
* filters; arrow keys + enter pick; clicking outside or ESC closes.
|
||||||
|
* Picking a tag sets state.activeTags = [tag] and re-renders the board. */
|
||||||
|
function openTagQuickPick() {
|
||||||
|
const existing = document.getElementById('tag-quickpick');
|
||||||
|
if (existing) { existing.remove(); return; }
|
||||||
|
|
||||||
|
const tagSet = new Set();
|
||||||
|
for (const c of state.cards) {
|
||||||
|
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
|
||||||
|
}
|
||||||
|
const allTags = [...tagSet].sort();
|
||||||
|
if (!allTags.length) return;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'tag-quickpick';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;';
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="padding:10px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
|
||||||
|
<i class="ph ph-funnel text-t-accent"></i>
|
||||||
|
<input id="tag-quickpick-input" type="text" placeholder="filter tags…"
|
||||||
|
style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:6px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div id="tag-quickpick-list" style="flex:1;overflow-y:auto;padding:4px 0;"></div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = box.querySelector('#tag-quickpick-input');
|
||||||
|
const list = box.querySelector('#tag-quickpick-list');
|
||||||
|
let filtered = allTags;
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!filtered.length) {
|
||||||
|
list.innerHTML = '<div style="padding:14px 12px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no tags match</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = filtered.map((t, i) => `
|
||||||
|
<button class="tag-quickpick-row" data-tag="${escHtml(t)}" data-idx="${i}"
|
||||||
|
style="display:block;width:100%;text-align:left;padding:6px 14px;font-size:11px;font-family:var(--t-font-pixel,'Silkscreen',cursive);color:${i === active ? 'var(--t-bright)' : 'var(--t-text)'};background:${i === active ? 'var(--t-raised)' : 'transparent'};border:none;cursor:pointer;">
|
||||||
|
#${escHtml(t)}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
list.querySelectorAll('[data-tag]').forEach(btn => {
|
||||||
|
btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); });
|
||||||
|
btn.addEventListener('click', () => pick(btn.dataset.tag));
|
||||||
|
});
|
||||||
|
const activeEl = list.querySelector(`[data-idx="${active}"]`);
|
||||||
|
activeEl?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(tag) {
|
||||||
|
state.activeTags = [tag];
|
||||||
|
close();
|
||||||
|
window._renderTagBar?.();
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const f = input.value.toLowerCase().replace(/^#/, '');
|
||||||
|
filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags;
|
||||||
|
active = 0;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
|
// Capture-phase keydown so ESC etc. don't bubble back to the rest of
|
||||||
|
// the page handlers (search, board) while the picker is open.
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot ──
|
// ── Boot ──
|
||||||
|
|
||||||
|
renderShortcutHelp();
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||||
+216
-29
@@ -23,6 +23,68 @@ const boardPreviewTags = $('board-preview-tags');
|
|||||||
const boardPreviewMeta = $('board-preview-meta');
|
const boardPreviewMeta = $('board-preview-meta');
|
||||||
let activeDragEl = null;
|
let activeDragEl = null;
|
||||||
let dragGhost = null;
|
let dragGhost = null;
|
||||||
|
// Set of card ids currently being dragged. Module-scoped so a poll-driven
|
||||||
|
// re-render (caldav/collab) can re-apply the `.card-dragging` ghost class
|
||||||
|
// to the matching DOM nodes after `board.innerHTML = …` wipes them. Without
|
||||||
|
// this the dragged card would visually pop back to its normal state every
|
||||||
|
// time a background poll completed mid-drag.
|
||||||
|
const draggingCardIds = new Set();
|
||||||
|
|
||||||
|
// Tracks every card id we've ever rendered. On the first render we just
|
||||||
|
// snapshot whatever's currently in state.cards (everything counts as
|
||||||
|
// "already known"); after that, the diff against this set tells us
|
||||||
|
// which cards just arrived from a poll / sync. New cards get pushed into
|
||||||
|
// state.unseenCardIds and either pulse immediately (if we're already on
|
||||||
|
// the all view) or wait for the next visit to the all tab to pulse.
|
||||||
|
let knownCardIds = null;
|
||||||
|
|
||||||
|
/** Animate card removal: adds .card-removing to each matching card
|
||||||
|
* element and waits for the CSS animation to finish before resolving.
|
||||||
|
* Call this BEFORE the API delete + _reloadAll so the user sees the
|
||||||
|
* card shrink/fade out instead of just vanishing. */
|
||||||
|
export function animateCardRemoval(ids) {
|
||||||
|
if (!ids || !ids.length) return Promise.resolve();
|
||||||
|
const els = ids.map(id => board.querySelector(`[data-id="${id}"]`)).filter(Boolean);
|
||||||
|
if (!els.length) return Promise.resolve();
|
||||||
|
for (const el of els) el.classList.add('card-removing');
|
||||||
|
return new Promise(r => setTimeout(r, 260));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a pulse class to matching board cards and strip it after the
|
||||||
|
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
||||||
|
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
||||||
|
* just created themselves). */
|
||||||
|
function pulseCards(ids, cls = 'card-pulse-new') {
|
||||||
|
if (!ids || !ids.size) return;
|
||||||
|
const durationMs = cls === 'card-pulse-local' ? 900 : 2400;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
|
if (!el) continue;
|
||||||
|
el.classList.add(cls);
|
||||||
|
setTimeout(() => el.classList.remove(cls), durationMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden
|
||||||
|
* while the user is currently looking at the all view. Lives in
|
||||||
|
* board.js (rather than buckets.js) because the trigger is "new card
|
||||||
|
* arrived" — buckets.js doesn't know about that, board.js does. */
|
||||||
|
function decorateAllUnreadDot() {
|
||||||
|
const allTab = document.querySelector('#bucket-bar [data-bucket="all"]');
|
||||||
|
if (!allTab) return;
|
||||||
|
const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all';
|
||||||
|
allTab.classList.toggle('has-unread', hasUnread);
|
||||||
|
let dot = allTab.querySelector('.bar-unread-dot');
|
||||||
|
if (hasUnread && !dot) {
|
||||||
|
dot = document.createElement('span');
|
||||||
|
dot.className = 'bar-unread-dot';
|
||||||
|
allTab.appendChild(dot);
|
||||||
|
} else if (!hasUnread && dot) {
|
||||||
|
dot.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Multi-select ──
|
// ── Multi-select ──
|
||||||
const selectedIds = state.selectedIds;
|
const selectedIds = state.selectedIds;
|
||||||
@@ -60,6 +122,7 @@ function toggleSelect(id) {
|
|||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
board.querySelectorAll('.card-selected').forEach(el => {
|
board.querySelectorAll('.card-selected').forEach(el => {
|
||||||
el.classList.remove('card-selected');
|
el.classList.remove('card-selected');
|
||||||
el.querySelector('.card-select-check')?.remove();
|
el.querySelector('.card-select-check')?.remove();
|
||||||
@@ -70,21 +133,9 @@ function clearSelection() {
|
|||||||
export function getSelectedIds() { return [...selectedIds]; }
|
export function getSelectedIds() { return [...selectedIds]; }
|
||||||
export { clearSelection };
|
export { clearSelection };
|
||||||
|
|
||||||
// Delete/Backspace deletes selected cards
|
// Delete/Backspace for selected cards is now in the central shortcuts
|
||||||
document.addEventListener('keydown', async e => {
|
// registry (app.js registerShortcut). It uses getSelectedIds() /
|
||||||
if (!selectedIds.size) return;
|
// clearSelection() which are already exported.
|
||||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
|
||||||
const { showDialog } = await import('./dialog.js');
|
|
||||||
const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
|
|
||||||
clearSelection();
|
|
||||||
await window._reloadAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selection on click outside board (header, footer, etc)
|
// Clear selection on click outside board (header, footer, etc)
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
@@ -111,6 +162,16 @@ $('select-group')?.addEventListener('click', async () => {
|
|||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('select-join')?.addEventListener('click', async () => {
|
||||||
|
if (selectedIds.size < 2) return;
|
||||||
|
// Dynamic import keeps the board → context-menu edge lazy and avoids
|
||||||
|
// circular import surprises at module load time. joinSelectedCards
|
||||||
|
// reads state.selectedIds + state.selectionWasBulk and handles its
|
||||||
|
// own reload + selection clear.
|
||||||
|
const { joinSelectedCards } = await import('./context-menu.js');
|
||||||
|
await joinSelectedCards();
|
||||||
|
});
|
||||||
|
|
||||||
$('select-bucket')?.addEventListener('click', () => {
|
$('select-bucket')?.addEventListener('click', () => {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
// Remove existing picker
|
// Remove existing picker
|
||||||
@@ -295,8 +356,11 @@ function closeBoardPreview() {
|
|||||||
boardPreview?.classList.add('hidden');
|
boardPreview?.classList.add('hidden');
|
||||||
state.boardSplitOpen = false;
|
state.boardSplitOpen = false;
|
||||||
localStorage.setItem('hyprcrd-board-split', '0');
|
localStorage.setItem('hyprcrd-board-split', '0');
|
||||||
|
// The toolbar's active state lives on the `.active` class — the
|
||||||
|
// previous `text-t-accent` removal was a no-op since that class
|
||||||
|
// isn't what toggleBoardSplit uses.
|
||||||
const btn = $('board-split-toggle');
|
const btn = $('board-split-toggle');
|
||||||
if (btn) btn.classList.remove('text-t-accent');
|
if (btn) btn.classList.remove('active');
|
||||||
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
||||||
state.boardPreviewCardId = null;
|
state.boardPreviewCardId = null;
|
||||||
resetBoardPreview();
|
resetBoardPreview();
|
||||||
@@ -430,6 +494,24 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
export function renderBoard() {
|
export function renderBoard() {
|
||||||
|
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||||
|
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||||
|
// (initial empty state, before the API has returned), so we wait
|
||||||
|
// until knownCardIds is non-empty before doing the diff — otherwise
|
||||||
|
// the first real load looks like "every card just arrived" and the
|
||||||
|
// entire board pulses on page open.
|
||||||
|
const currentIds = new Set(state.cards.map(c => String(c.id)));
|
||||||
|
const newLocalIds = new Set();
|
||||||
|
if (knownCardIds && knownCardIds.size > 0) {
|
||||||
|
for (const id of currentIds) {
|
||||||
|
if (knownCardIds.has(id)) continue;
|
||||||
|
// Locally-created cards go into a separate set — they get a
|
||||||
|
// 1× pulse but must NOT trigger the unread dot on the all-tab.
|
||||||
|
if (state.recentlyCreatedIds.has(id)) newLocalIds.add(id);
|
||||||
|
else state.unseenCardIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
knownCardIds = currentIds;
|
||||||
let cards;
|
let cards;
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
@@ -515,10 +597,9 @@ export function renderBoard() {
|
|||||||
const inGroup = !!state.activeGroup;
|
const inGroup = !!state.activeGroup;
|
||||||
|
|
||||||
if (state.superView) {
|
if (state.superView) {
|
||||||
// Compact multi-column list view — override board grid
|
// Single-column list view — override board grid
|
||||||
board.style.display = 'block';
|
board.style.display = 'block';
|
||||||
const cols = cards.length <= 20 ? 2 : cards.length <= 50 ? 3 : cards.length <= 100 ? 4 : 5;
|
board.innerHTML = `<div class="superview-grid">` + cards.map(c => {
|
||||||
board.innerHTML = `<div class="superview-grid" style="column-count:${cols};">` + cards.map(c => {
|
|
||||||
const tags = parseTags(c);
|
const tags = parseTags(c);
|
||||||
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
||||||
let calCfg = null;
|
let calCfg = null;
|
||||||
@@ -526,10 +607,26 @@ export function renderBoard() {
|
|||||||
const dotColor = calCfg?.color || c.color;
|
const dotColor = calCfg?.color || c.color;
|
||||||
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
||||||
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
|
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
|
||||||
|
// Date column: due_date if set, else date prefix in title, else updated/created
|
||||||
|
const dateStr = c.due_date
|
||||||
|
|| ((c.title || '').match(/^\d{4}-\d{2}-\d{2}/)?.[0])
|
||||||
|
|| (c.updated_at || c.created_at || '').substring(0, 10);
|
||||||
|
// Tag chips (excluding termin/geburtstag — those are already
|
||||||
|
// represented by the icon + dot on the left)
|
||||||
|
const visibleTags = tags.filter(t => t !== 'termin' && t !== 'geburtstag');
|
||||||
|
const tagsHtml = visibleTags.length
|
||||||
|
? `<span class="superview-tags">${visibleTags.slice(0, 4).map(t => `<span class="superview-tag">#${esc(t)}</span>`).join('')}${visibleTags.length > 4 ? `<span class="superview-tag superview-tag-more">+${visibleTags.length - 4}</span>` : ''}</span>`
|
||||||
|
: '';
|
||||||
|
const bucketHtml = c.bucket_name
|
||||||
|
? `<span class="superview-bucket"><i class="ph ph-folder"></i>${esc(c.bucket_name)}</span>`
|
||||||
|
: '';
|
||||||
return `<div class="superview-item" data-id="${c.id}">
|
return `<div class="superview-item" data-id="${c.id}">
|
||||||
<span class="superview-dot" style="background:${dotColor}"></span>
|
<span class="superview-dot" style="background:${dotColor}"></span>
|
||||||
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
||||||
<span class="superview-title">${esc(c.title)}</span>
|
<span class="superview-title">${esc(c.title)}</span>
|
||||||
|
${tagsHtml}
|
||||||
|
${bucketHtml}
|
||||||
|
<span class="superview-date">${dateStr}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
|
|
||||||
@@ -614,6 +711,19 @@ export function renderBoard() {
|
|||||||
|
|
||||||
bindCardEvents();
|
bindCardEvents();
|
||||||
|
|
||||||
|
// Re-apply the dragging ghost to any card whose drag is still in
|
||||||
|
// flight. Polls (caldav, collab) call _reloadAll → renderBoard mid-
|
||||||
|
// drag, which would otherwise wipe the visual ghost state without
|
||||||
|
// the underlying drag operation noticing.
|
||||||
|
if (draggingCardIds.size) {
|
||||||
|
draggingCardIds.forEach(did => {
|
||||||
|
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore arrow-key card focus if the focused card is still rendered
|
||||||
|
window._restoreCardFocus?.();
|
||||||
|
|
||||||
// Restore selection state
|
// Restore selection state
|
||||||
selectedIds.forEach(id => {
|
selectedIds.forEach(id => {
|
||||||
const el = board.querySelector(`[data-id="${id}"]`);
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
@@ -641,6 +751,23 @@ export function renderBoard() {
|
|||||||
|
|
||||||
// Notification banner
|
// Notification banner
|
||||||
renderNotifs();
|
renderNotifs();
|
||||||
|
|
||||||
|
// Pulse locally-created cards in ANY bucket (1× subtle pulse).
|
||||||
|
// These never enter unseenCardIds so they can't trigger the all-tab
|
||||||
|
// dot — the user just made them, there's nothing "unseen" about it.
|
||||||
|
if (newLocalIds.size) {
|
||||||
|
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
|
||||||
|
pulseCards(newLocalIds, 'card-pulse-local');
|
||||||
|
}
|
||||||
|
// Remote (relay-synced) cards: pulse 3× only when the user is on
|
||||||
|
// the all view. On other views, the unseen ids accumulate and
|
||||||
|
// the all-tab gets a dot until the user opens it.
|
||||||
|
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
|
||||||
|
const remote = new Set(state.unseenCardIds);
|
||||||
|
state.unseenCardIds.clear();
|
||||||
|
pulseCards(remote, 'card-pulse-new');
|
||||||
|
}
|
||||||
|
decorateAllUnreadDot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification providers ──
|
// ── Notification providers ──
|
||||||
@@ -843,6 +970,36 @@ function hideFan() {
|
|||||||
// ── Card event binding ──
|
// ── Card event binding ──
|
||||||
|
|
||||||
function bindCardEvents() {
|
function bindCardEvents() {
|
||||||
|
// Single insertion-cursor element that lives inside #board (which is
|
||||||
|
// `relative`) and gets repositioned by the card-level dragover handlers
|
||||||
|
// below. Replaces the previous `.card-drop-before::before` /
|
||||||
|
// `.card-drop-after::after` pseudo-elements so the cursor is a real
|
||||||
|
// element in the gap rather than a sliver glued to the side of a tile.
|
||||||
|
let cardDropCursor = board.querySelector('#card-drop-cursor');
|
||||||
|
if (!cardDropCursor) {
|
||||||
|
cardDropCursor = document.createElement('div');
|
||||||
|
cardDropCursor.id = 'card-drop-cursor';
|
||||||
|
board.appendChild(cardDropCursor);
|
||||||
|
}
|
||||||
|
function showCardDropCursor(targetEl, before) {
|
||||||
|
// offsetLeft / offsetTop / offsetHeight are relative to the
|
||||||
|
// offsetParent (#board) so the cursor positions correctly even
|
||||||
|
// when #board is scrolled vertically.
|
||||||
|
const x = before
|
||||||
|
? targetEl.offsetLeft - 6
|
||||||
|
: targetEl.offsetLeft + targetEl.offsetWidth + 2;
|
||||||
|
cardDropCursor.style.left = x + 'px';
|
||||||
|
cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px';
|
||||||
|
cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px';
|
||||||
|
cardDropCursor.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideCardDropCursor() {
|
||||||
|
cardDropCursor.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Expose so the global dragend handler (defined outside this function)
|
||||||
|
// can hide the cursor too.
|
||||||
|
board._hideCardDropCursor = hideCardDropCursor;
|
||||||
|
|
||||||
board.querySelectorAll('[data-group]').forEach(badge => {
|
board.querySelectorAll('[data-group]').forEach(badge => {
|
||||||
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
||||||
});
|
});
|
||||||
@@ -932,8 +1089,11 @@ function bindCardEvents() {
|
|||||||
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
||||||
const dragIds = isMulti ? [...selectedIds] : [String(id)];
|
const dragIds = isMulti ? [...selectedIds] : [String(id)];
|
||||||
|
|
||||||
// Ghost all dragged cards
|
// Ghost all dragged cards + remember the ids so a poll-driven
|
||||||
|
// re-render can re-apply the ghost state.
|
||||||
|
draggingCardIds.clear();
|
||||||
dragIds.forEach(did => {
|
dragIds.forEach(did => {
|
||||||
|
draggingCardIds.add(String(did));
|
||||||
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -979,20 +1139,36 @@ function bindCardEvents() {
|
|||||||
el.addEventListener('dragend', () => {
|
el.addEventListener('dragend', () => {
|
||||||
// Remove ghost class from all dragged cards
|
// Remove ghost class from all dragged cards
|
||||||
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
||||||
|
draggingCardIds.clear();
|
||||||
activeDragEl = null;
|
activeDragEl = null;
|
||||||
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
||||||
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
|
board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group'));
|
||||||
x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
hideCardDropCursor();
|
||||||
});
|
});
|
||||||
el.addEventListener('dragover', e => {
|
el.addEventListener('dragover', e => {
|
||||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
||||||
el.classList.toggle('card-drop-before', x < edge);
|
// Three zones (matching the bucket fix): left 25% / right 25%
|
||||||
el.classList.toggle('card-drop-after', x > rect.width - edge);
|
// → insertion cursor in the gap, center 50% → group highlight
|
||||||
el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
|
// on the target card.
|
||||||
|
const isBefore = x < edge;
|
||||||
|
const isAfter = x > rect.width - edge;
|
||||||
|
const isGroup = !isBefore && !isAfter;
|
||||||
|
el.classList.toggle('card-drop-group', isGroup);
|
||||||
|
if (isGroup) {
|
||||||
|
hideCardDropCursor();
|
||||||
|
} else {
|
||||||
|
showCardDropCursor(el, isBefore);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.addEventListener('dragleave', () => {
|
||||||
|
el.classList.remove('card-drop-group');
|
||||||
|
// Don't hide the cursor here — the next card's dragover will
|
||||||
|
// reposition it. Hiding on dragleave makes the cursor flicker
|
||||||
|
// as the mouse moves across adjacent cards. Final cleanup
|
||||||
|
// happens on dragend.
|
||||||
});
|
});
|
||||||
el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
|
||||||
el.addEventListener('drop', e => handleCardDrop(e, el, id));
|
el.addEventListener('drop', e => handleCardDrop(e, el, id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1001,9 +1177,17 @@ function bindCardEvents() {
|
|||||||
|
|
||||||
async function handleCardDrop(e, el, targetId) {
|
async function handleCardDrop(e, el, targetId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const wasGroup = el.classList.contains('card-drop-group');
|
// Recompute the drop zone from the mouse position rather than reading
|
||||||
const wasBefore = el.classList.contains('card-drop-before');
|
// it back from CSS classes — the new cursor-based dragover doesn't
|
||||||
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
|
// tag the target with `.card-drop-before` / `.card-drop-after` anymore,
|
||||||
|
// only `.card-drop-group` for the center hover state.
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const edge = rect.width * 0.25;
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const wasGroup = !(x < edge) && !(x > rect.width - edge);
|
||||||
|
const wasBefore = x < edge;
|
||||||
|
el.classList.remove('card-drop-group');
|
||||||
|
board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none');
|
||||||
const dragId = e.dataTransfer.getData('text/plain');
|
const dragId = e.dataTransfer.getData('text/plain');
|
||||||
if (!dragId || dragId === targetId) return;
|
if (!dragId || dragId === targetId) return;
|
||||||
|
|
||||||
@@ -1105,6 +1289,9 @@ board.addEventListener('mousedown', e => {
|
|||||||
if (!boxActive) {
|
if (!boxActive) {
|
||||||
boxActive = true;
|
boxActive = true;
|
||||||
if (!additive) clearSelection();
|
if (!additive) clearSelection();
|
||||||
|
// Mark the selection as bulk so the join action knows to use
|
||||||
|
// the longest-title strategy instead of first-clicked.
|
||||||
|
state.selectionWasBulk = true;
|
||||||
boxEl = document.createElement('div');
|
boxEl = document.createElement('div');
|
||||||
boxEl.className = 'box-select';
|
boxEl.className = 'box-select';
|
||||||
board.appendChild(boxEl);
|
board.appendChild(boxEl);
|
||||||
|
|||||||
+376
-36
@@ -1,8 +1,9 @@
|
|||||||
// ── Bucket management ──
|
// ── Bucket management ──
|
||||||
|
|
||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, CARD_COLORS } from './state.js';
|
import { state, CARD_COLORS, setActiveBucket, ensureActiveBucketValid } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
import { pushUndo } from './undo.js';
|
||||||
|
|
||||||
// ── Bucket context menu ──
|
// ── Bucket context menu ──
|
||||||
|
|
||||||
@@ -19,14 +20,30 @@ document.addEventListener('click', hideBucketCtx);
|
|||||||
|
|
||||||
async function showBucketCtx(x, y, bucket) {
|
async function showBucketCtx(x, y, bucket) {
|
||||||
ctxBucketId = bucket.id;
|
ctxBucketId = bucket.id;
|
||||||
const idx = state.buckets.findIndex(b => b.id == bucket.id);
|
// Move-left / move-right operates within the bucket's siblings —
|
||||||
|
// top-level peers if the bucket is top-level, dropdown peers if it's
|
||||||
|
// a super-bucket child. This keeps reordering scoped to where the
|
||||||
|
// user actually sees the bucket in the UI.
|
||||||
|
const siblings = state.buckets
|
||||||
|
.filter(b => (b.parent_id || null) === (bucket.parent_id || null))
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
const idx = siblings.findIndex(b => b.id == bucket.id);
|
||||||
const canLeft = idx > 0;
|
const canLeft = idx > 0;
|
||||||
const canRight = idx < state.buckets.length - 1;
|
const canRight = idx >= 0 && idx < siblings.length - 1;
|
||||||
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
|
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
|
||||||
|
|
||||||
|
const isCollab = !!bucket.collab_uid;
|
||||||
ctxMenu.innerHTML = `<div class="py-1">
|
ctxMenu.innerHTML = `<div class="py-1">
|
||||||
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Rename</span></button>
|
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Rename</span></button>
|
||||||
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
|
${isCollab && typeof window._collabBucketsEditDialog === 'function'
|
||||||
|
? `<button class="ctx-item" data-baction="collab-manage"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Members & owner…</span></button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
|
||||||
|
? `<button class="ctx-item" data-baction="collab-upgrade"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Make collaborative…</span></button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
||||||
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Move left</span></button>
|
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Move left</span></button>
|
||||||
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">Move right</span></button>
|
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">Move right</span></button>
|
||||||
@@ -56,32 +73,81 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
document.getElementById('bctx-colors').classList.toggle('hidden');
|
document.getElementById('bctx-colors').classList.toggle('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
else if (a === 'collab-manage') {
|
||||||
|
hideBucketCtx();
|
||||||
|
if (typeof window._collabBucketsEditDialog === 'function') {
|
||||||
|
const changed = await window._collabBucketsEditDialog(bucket.collab_uid);
|
||||||
|
if (changed) await window._reloadAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (a === 'collab-upgrade') {
|
||||||
|
hideBucketCtx();
|
||||||
|
if (typeof window._collabBucketsUpgradeDialog === 'function') {
|
||||||
|
const changed = await window._collabBucketsUpgradeDialog(bucket.id);
|
||||||
|
if (changed) await window._reloadAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
else if (a === 'rename') {
|
else if (a === 'rename') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
|
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
|
// Collab buckets: push name change through the plugin (relay +
|
||||||
|
// mirror). The next poll pulls the new name into the local row.
|
||||||
|
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
|
||||||
|
const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() });
|
||||||
|
if (!res?.ok) { alert(res?.error || 'rename failed'); return; }
|
||||||
|
} else {
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
||||||
await loadBuckets(); window._renderBoard();
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'move-left' || a === 'move-right') {
|
else if (a === 'move-left' || a === 'move-right') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const swapIdx = a === 'move-left' ? idx - 1 : idx + 1;
|
const other = siblings[a === 'move-left' ? idx - 1 : idx + 1];
|
||||||
const other = state.buckets[swapIdx];
|
|
||||||
if (!other) return;
|
if (!other) return;
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } });
|
// Swap positions of two siblings — works for both top-level
|
||||||
await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } });
|
// and dropdown reordering since `siblings` is scoped above.
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: other.position ?? 0 } });
|
||||||
|
await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: bucket.position ?? 0 } });
|
||||||
await loadBuckets(); window._renderBoard();
|
await loadBuckets(); window._renderBoard();
|
||||||
}
|
}
|
||||||
else if (a === 'delete-keep') {
|
else if (a === 'delete-keep') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'Delete' });
|
const msg = bucket.collab_uid
|
||||||
|
? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`)
|
||||||
|
: (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`);
|
||||||
|
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
|
// Collab bucket: route through plugin so the relay is notified
|
||||||
|
// (owner = delete for everyone; member = just leave).
|
||||||
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
|
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
||||||
|
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
||||||
|
// Undo is intentionally NOT wired for collab deletes/leaves
|
||||||
|
// — restoring would require re-creating on the relay and
|
||||||
|
// re-attaching cards under a new uid. Too messy for MVP.
|
||||||
|
} else {
|
||||||
|
// Snapshot the row + affected card ids before deleting so
|
||||||
|
// the undo entry can restore them.
|
||||||
|
const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position };
|
||||||
|
const affected = cards.map(c => c.id);
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||||
await loadBuckets(); await window._reloadAll();
|
pushUndo(`delete bucket "${bucket.name}"`, async () => {
|
||||||
|
const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } });
|
||||||
|
if (created?.id) {
|
||||||
|
await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } });
|
||||||
|
// Re-attach cards to the recreated bucket
|
||||||
|
await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'delete-all') {
|
else if (a === 'delete-all') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
@@ -89,9 +155,14 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
|
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
|
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
||||||
|
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
||||||
|
} else {
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||||
await loadBuckets(); await window._reloadAll();
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -99,9 +170,14 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
|
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
|
const newColor = btn.dataset.bctxColor;
|
||||||
await loadBuckets();
|
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
|
||||||
window._renderBoard();
|
const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor });
|
||||||
|
if (!res?.ok) { alert(res?.error || 'color change failed'); return; }
|
||||||
|
} else {
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } });
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +186,24 @@ const inpBucket = $('inp-bucket');
|
|||||||
const searchFilter = $('search-bucket-filter');
|
const searchFilter = $('search-bucket-filter');
|
||||||
const tasksFilter = $('tasks-bucket-filter');
|
const tasksFilter = $('tasks-bucket-filter');
|
||||||
|
|
||||||
|
/** Switch the active bucket without going through the click path —
|
||||||
|
* used by the number-key hotkeys (and any other code that wants to
|
||||||
|
* activate a bucket programmatically without triggering the super-
|
||||||
|
* bucket dropdown). Persists via setActiveBucket and re-renders the
|
||||||
|
* bar + board so the active highlight tracks. */
|
||||||
|
export function activateBucket(idOrSpecial) {
|
||||||
|
setActiveBucket(idOrSpecial);
|
||||||
|
state.activeGroup = null;
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadBuckets() {
|
export async function loadBuckets() {
|
||||||
state.buckets = await api('api/buckets');
|
state.buckets = await api('api/buckets');
|
||||||
|
// The persisted activeBucket may point to a bucket that no longer
|
||||||
|
// exists (deleted in another tab, removed via import, etc.). Reset
|
||||||
|
// to 'all' in that case before rendering.
|
||||||
|
ensureActiveBucketValid();
|
||||||
renderBar();
|
renderBar();
|
||||||
populateSelects();
|
populateSelects();
|
||||||
}
|
}
|
||||||
@@ -150,17 +242,75 @@ function updateHeaderTint() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remembers the last active member per super-bucket so the tile doesn't
|
||||||
|
// fall back to the parent label when the user navigates away and comes
|
||||||
|
// back. Keyed by parent bucket id → last selected member id (could be
|
||||||
|
// the parent itself or any child).
|
||||||
|
const lastSuperSelection = new Map();
|
||||||
|
|
||||||
function renderBar() {
|
function renderBar() {
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
|
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
|
||||||
|
|
||||||
|
// Top-level buckets only — children are rendered inside their parent's
|
||||||
|
// dropdown popover, not directly in the bar.
|
||||||
|
const topLevel = state.buckets.filter(b => !b.parent_id);
|
||||||
|
const childrenOf = (parentId) => state.buckets
|
||||||
|
.filter(b => b.parent_id == parentId)
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
|
||||||
|
// Track which super-bucket member is active NOW so we can recall it
|
||||||
|
// later when the user comes back from a different bucket.
|
||||||
|
for (const b of topLevel) {
|
||||||
|
const kids = childrenOf(b.id);
|
||||||
|
if (!kids.length) continue;
|
||||||
|
if (state.activeBucket == b.id) lastSuperSelection.set(b.id, b.id);
|
||||||
|
const activeKid = kids.find(c => c.id == state.activeBucket);
|
||||||
|
if (activeKid) lastSuperSelection.set(b.id, activeKid.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve which child / parent to *show* on a super-bucket tile.
|
||||||
|
// Priority: currently active member → last selected member → parent.
|
||||||
|
const tileFor = (parent) => {
|
||||||
|
const kids = childrenOf(parent.id);
|
||||||
|
if (!kids.length) return null; // not a super-bucket
|
||||||
|
const activeChild = kids.find(c => c.id == state.activeBucket);
|
||||||
|
if (activeChild) return activeChild;
|
||||||
|
if (state.activeBucket == parent.id) return parent;
|
||||||
|
// Fall back to the last selection rather than the parent
|
||||||
|
const lastId = lastSuperSelection.get(parent.id);
|
||||||
|
if (lastId) {
|
||||||
|
if (lastId == parent.id) return parent;
|
||||||
|
const lastKid = kids.find(c => c.id == lastId);
|
||||||
|
if (lastKid) return lastKid;
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show an unread dot on the fixed `all` tab when state.unseenCardIds
|
||||||
|
// has anything in it AND the user isn't currently looking at the
|
||||||
|
// all view. board.js drives the unseen set; we just read it here so
|
||||||
|
// the dot survives bar re-renders that aren't followed by a board
|
||||||
|
// render (e.g. bucket reorder, plain renderBar() call).
|
||||||
|
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
||||||
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
||||||
${state.buckets.map(b => {
|
${topLevel.map(b => {
|
||||||
|
const kids = childrenOf(b.id);
|
||||||
|
const isSuper = kids.length > 0;
|
||||||
|
if (isSuper) {
|
||||||
|
// Super-bucket tile: shows the active member's name + a stack icon.
|
||||||
|
// The shown member's id goes on `data-super-shown` so we can
|
||||||
|
// highlight + recolor without taking over the dropdown click.
|
||||||
|
const shown = tileFor(b);
|
||||||
|
const c = shown.color || '#64748b';
|
||||||
|
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
|
||||||
|
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`;
|
||||||
|
}
|
||||||
const c = b.color || '#64748b';
|
const c = b.color || '#64748b';
|
||||||
const active = state.activeBucket == b.id;
|
const active = state.activeBucket == b.id;
|
||||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||||
@@ -171,11 +321,156 @@ function renderBar() {
|
|||||||
|
|
||||||
let draggingBucketId = null;
|
let draggingBucketId = null;
|
||||||
|
|
||||||
bar.querySelectorAll('.bucket-tab').forEach(t => {
|
// Single insertion-cursor element shared by all bucket tabs. Lives
|
||||||
const bid = t.dataset.bucket;
|
// inside #bucket-bar (which is `position: relative` so absolute
|
||||||
const isUser = bid !== 'all' && bid !== 'none';
|
// children align to its scroll-aware coord space) and gets repositioned
|
||||||
|
// by the dragover handlers below. The cursor sits *between* tiles,
|
||||||
|
// not as a border on the target tile, so it stays unambiguous when
|
||||||
|
// we add the super-bucket nest gesture later.
|
||||||
|
let dropCursor = bar.querySelector('#bucket-drop-cursor');
|
||||||
|
if (!dropCursor) {
|
||||||
|
dropCursor = document.createElement('div');
|
||||||
|
dropCursor.id = 'bucket-drop-cursor';
|
||||||
|
bar.appendChild(dropCursor);
|
||||||
|
}
|
||||||
|
function showDropCursor(targetTab, before) {
|
||||||
|
// Position the cursor at the gap on the chosen side of the target.
|
||||||
|
// offsetLeft / offsetWidth are relative to the offsetParent
|
||||||
|
// (#bucket-bar) so they survive horizontal scroll.
|
||||||
|
const x = before
|
||||||
|
? targetTab.offsetLeft - 4
|
||||||
|
: targetTab.offsetLeft + targetTab.offsetWidth + 1;
|
||||||
|
dropCursor.style.left = x + 'px';
|
||||||
|
dropCursor.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideDropCursor() {
|
||||||
|
dropCursor.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
|
/** Can `draggedId` legally become a child of `targetId`?
|
||||||
|
* Rules: 1-level deep only, no self-parenting, target must be a real
|
||||||
|
* user bucket and currently top-level, dragged must not have children. */
|
||||||
|
function canNestInto(draggedId, targetId) {
|
||||||
|
if (!Number.isFinite(draggedId) || !Number.isFinite(targetId)) return false;
|
||||||
|
if (draggedId === targetId) return false;
|
||||||
|
const target = state.buckets.find(b => b.id === targetId);
|
||||||
|
if (!target || target.parent_id) return false;
|
||||||
|
const dragged = state.buckets.find(b => b.id === draggedId);
|
||||||
|
if (!dragged) return false;
|
||||||
|
// Already a child of the same target → no-op nest
|
||||||
|
if (dragged.parent_id === targetId) return false;
|
||||||
|
// Dragged has its own children → would create level-2
|
||||||
|
if (state.buckets.some(b => b.parent_id === draggedId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a popover under a super-bucket tile, listing parent + every
|
||||||
|
* child. Each entry is a real `.bucket-tab[data-bucket=...]` element
|
||||||
|
* so the collab plugin's MutationObserver still picks them up and
|
||||||
|
* decorates them with the unread dot / users-three icon. */
|
||||||
|
function openSuperDropdown(anchorTab, parentId) {
|
||||||
|
// Toggle: if a popover is already open for this parent, close it.
|
||||||
|
const existing = document.getElementById('bucket-super-popover');
|
||||||
|
if (existing) {
|
||||||
|
const wasFor = existing.dataset.parentId;
|
||||||
|
existing.remove();
|
||||||
|
if (wasFor === String(parentId)) return;
|
||||||
|
}
|
||||||
|
const parent = state.buckets.find(b => b.id === parentId);
|
||||||
|
if (!parent) return;
|
||||||
|
const kids = state.buckets
|
||||||
|
.filter(b => b.parent_id === parentId)
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
const members = [parent, ...kids];
|
||||||
|
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.id = 'bucket-super-popover';
|
||||||
|
pop.dataset.parentId = String(parentId);
|
||||||
|
// Anchor under the tile, clamped to the viewport.
|
||||||
|
const rect = anchorTab.getBoundingClientRect();
|
||||||
|
pop.style.position = 'fixed';
|
||||||
|
pop.style.top = (rect.bottom + 4) + 'px';
|
||||||
|
pop.style.left = rect.left + 'px';
|
||||||
|
|
||||||
|
// Highlight the currently active member OR the last remembered
|
||||||
|
// selection (so the user sees which child was picked last time
|
||||||
|
// even when they're currently on a different bucket).
|
||||||
|
const lastId = lastSuperSelection.get(parentId);
|
||||||
|
const renderEntry = (m) => {
|
||||||
|
const c = m.color || '#64748b';
|
||||||
|
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
||||||
|
const isParent = m.id === parentId;
|
||||||
|
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" title="parent"></i>' : ''}</button>`;
|
||||||
|
};
|
||||||
|
pop.innerHTML = members.map(renderEntry).join('');
|
||||||
|
// Append inside #bucket-bar (not document.body) so the collab
|
||||||
|
// plugin's MutationObserver on #bucket-bar still picks up these
|
||||||
|
// .bucket-tab entries and decorates collab children with the
|
||||||
|
// unread dot + users-three icon. Position is `fixed` anyway so
|
||||||
|
// the bar's overflow:auto can't clip the popover.
|
||||||
|
bar.appendChild(pop);
|
||||||
|
|
||||||
|
// Wire each entry: click selects, contextmenu opens bucket ctx,
|
||||||
|
// drag starts a re-parent / re-order operation against the bar.
|
||||||
|
pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => {
|
||||||
|
const ebid = entry.dataset.bucket;
|
||||||
|
entry.addEventListener('click', () => {
|
||||||
|
setActiveBucket(ebid);
|
||||||
|
state.activeGroup = null;
|
||||||
|
pop.remove();
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
});
|
||||||
|
entry.addEventListener('contextmenu', e => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const bk = state.buckets.find(b => b.id == ebid);
|
||||||
|
if (bk) showBucketCtx(e.clientX, e.clientY, bk);
|
||||||
|
});
|
||||||
|
entry.addEventListener('dragstart', e => {
|
||||||
|
draggingBucketId = ebid;
|
||||||
|
e.dataTransfer.setData('application/x-bucket', ebid);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
entry.style.opacity = '0.3';
|
||||||
|
});
|
||||||
|
entry.addEventListener('dragend', () => {
|
||||||
|
entry.style.opacity = '';
|
||||||
|
draggingBucketId = null;
|
||||||
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside the popover closes it.
|
||||||
|
setTimeout(() => {
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!pop.contains(e.target) && !anchorTab.contains(e.target)) {
|
||||||
|
pop.remove();
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.querySelectorAll('.bucket-tab').forEach(t => {
|
||||||
|
// Super-bucket tile uses `data-bucket-super`; regular tiles use
|
||||||
|
// `data-bucket`. The id we resolve here is the parent's id for a
|
||||||
|
// super-tile (so drag/contextmenu/etc. operate on the parent).
|
||||||
|
const isSuperTile = t.classList.contains('bucket-super');
|
||||||
|
const bid = isSuperTile ? t.dataset.bucketSuper : t.dataset.bucket;
|
||||||
|
const SPECIAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||||
|
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
||||||
|
|
||||||
|
if (isSuperTile) {
|
||||||
|
// Click opens the dropdown popover instead of activating the
|
||||||
|
// parent directly. The dropdown lists parent + every child.
|
||||||
|
t.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openSuperDropdown(t, parseInt(bid, 10));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
||||||
|
}
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
t.addEventListener('contextmenu', e => {
|
t.addEventListener('contextmenu', e => {
|
||||||
@@ -194,18 +489,31 @@ function renderBar() {
|
|||||||
t.addEventListener('dragend', () => {
|
t.addEventListener('dragend', () => {
|
||||||
t.style.opacity = '';
|
t.style.opacity = '';
|
||||||
draggingBucketId = null;
|
draggingBucketId = null;
|
||||||
bar.querySelectorAll('.bucket-drop-left,.bucket-drop-right').forEach(x => x.classList.remove('bucket-drop-left', 'bucket-drop-right'));
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addEventListener('dragover', e => {
|
t.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggingBucketId && isUser && draggingBucketId !== bid) {
|
if (draggingBucketId && isUser && draggingBucketId !== bid) {
|
||||||
// Bucket reorder indicator
|
// Three drop zones along the tile's width:
|
||||||
|
// left 25% → insert before
|
||||||
|
// right 25% → insert after
|
||||||
|
// center 50% → nest into this bucket
|
||||||
const rect = t.getBoundingClientRect();
|
const rect = t.getBoundingClientRect();
|
||||||
const left = e.clientX < rect.left + rect.width / 2;
|
const x = e.clientX - rect.left;
|
||||||
t.classList.toggle('bucket-drop-left', left);
|
const isLeft = x < rect.width * 0.25;
|
||||||
t.classList.toggle('bucket-drop-right', !left);
|
const isRight = x > rect.width * 0.75;
|
||||||
|
const isCenter = !isLeft && !isRight;
|
||||||
|
if (isCenter && canNestInto(parseInt(draggingBucketId, 10), parseInt(bid, 10))) {
|
||||||
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
t.classList.add('bucket-nest-target');
|
||||||
|
} else {
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
showDropCursor(t, isLeft);
|
||||||
|
}
|
||||||
t.classList.remove('drag-over');
|
t.classList.remove('drag-over');
|
||||||
} else if (!draggingBucketId) {
|
} else if (!draggingBucketId) {
|
||||||
// Card drop on bucket
|
// Card drop on bucket
|
||||||
@@ -213,23 +521,44 @@ function renderBar() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
t.addEventListener('dragleave', () => {
|
t.addEventListener('dragleave', () => {
|
||||||
t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
|
t.classList.remove('drag-over');
|
||||||
});
|
});
|
||||||
t.addEventListener('drop', async e => {
|
t.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
|
t.classList.remove('drag-over', 'bucket-nest-target');
|
||||||
|
hideDropCursor();
|
||||||
|
|
||||||
// Bucket reorder
|
// Bucket reorder / nest
|
||||||
const dragBid = e.dataTransfer.getData('application/x-bucket');
|
const dragBid = e.dataTransfer.getData('application/x-bucket');
|
||||||
if (dragBid && isUser && dragBid !== bid) {
|
if (dragBid && isUser && dragBid !== bid) {
|
||||||
const rect = t.getBoundingClientRect();
|
const rect = t.getBoundingClientRect();
|
||||||
const before = e.clientX < rect.left + rect.width / 2;
|
const x = e.clientX - rect.left;
|
||||||
const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid);
|
const isLeft = x < rect.width * 0.25;
|
||||||
const targetIdx = ids.indexOf(String(bid));
|
const isRight = x > rect.width * 0.75;
|
||||||
ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid);
|
const isCenter = !isLeft && !isRight;
|
||||||
await Promise.all(ids.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } })));
|
if (isCenter && canNestInto(parseInt(dragBid, 10), parseInt(bid, 10))) {
|
||||||
|
// Nest: dragged becomes a child of target. Backend
|
||||||
|
// refuses cycles / level-2 with a 400 — bubble that
|
||||||
|
// up via alert so the user knows.
|
||||||
|
const res = await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: parseInt(bid, 10) } });
|
||||||
|
if (res?.error) { alert(res.error); return; }
|
||||||
|
await loadBuckets();
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reorder among top-level buckets only.
|
||||||
|
const before = isLeft || (!isRight && x < rect.width / 2);
|
||||||
|
const topIds = state.buckets.filter(b => !b.parent_id).map(b => String(b.id)).filter(i => i !== dragBid);
|
||||||
|
const targetIdx = topIds.indexOf(String(bid));
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
topIds.splice(before ? targetIdx : targetIdx + 1, 0, dragBid);
|
||||||
|
// The dragged bucket also gets un-nested if it was a child.
|
||||||
|
await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: null } });
|
||||||
|
await Promise.all(topIds.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } })));
|
||||||
await loadBuckets();
|
await loadBuckets();
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +575,10 @@ function renderBar() {
|
|||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid);
|
// Card drop on a super-bucket: card goes into the *currently
|
||||||
|
// shown* member of that group, not the parent itself.
|
||||||
|
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
||||||
|
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
||||||
if (bucketId === undefined) return;
|
if (bucketId === undefined) return;
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
||||||
window._clearSelection?.();
|
window._clearSelection?.();
|
||||||
@@ -256,6 +588,14 @@ function renderBar() {
|
|||||||
|
|
||||||
const addBtn = $('add-bucket-btn');
|
const addBtn = $('add-bucket-btn');
|
||||||
addBtn?.addEventListener('click', async () => {
|
addBtn?.addEventListener('click', async () => {
|
||||||
|
// Plugins (e.g. collab-buckets) can override the default create
|
||||||
|
// dialog by exposing window._collabBucketsCreateDialog. It returns
|
||||||
|
// true when a bucket was created, in which case we reload.
|
||||||
|
if (typeof window._collabBucketsCreateDialog === 'function') {
|
||||||
|
const created = await window._collabBucketsCreateDialog();
|
||||||
|
if (created) { await loadBuckets(); window._renderBoard(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ function openDayPopup(ds, cellEl) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
list.innerHTML = '<div style="padding:16px 12px;text-align:center;font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">no cards</div>';
|
list.innerHTML = '<div class="cursor-blink" style="padding:16px 12px;text-align:center;font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">no cards</div>';
|
||||||
}
|
}
|
||||||
dayPopup.appendChild(list);
|
dayPopup.appendChild(list);
|
||||||
|
|
||||||
@@ -506,6 +506,9 @@ export function openCalendar() {
|
|||||||
export function closeCalendar() {
|
export function closeCalendar() {
|
||||||
closeDayPopup();
|
closeDayPopup();
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('calendar-btn')?.addEventListener('click', openCalendar);
|
$('calendar-btn')?.addEventListener('click', openCalendar);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
|
import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js';
|
||||||
|
import { stripMd } from './markdown.js';
|
||||||
|
|
||||||
const menu = $('ctx-menu');
|
const menu = $('ctx-menu');
|
||||||
const items = $('ctx-items');
|
const items = $('ctx-items');
|
||||||
@@ -31,19 +32,112 @@ menu.addEventListener('mouseenter', () => {
|
|||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge every currently-selected card into a single card. Title strategy:
|
||||||
|
* if the selection involved a bulk gesture (rubber-band) we use the
|
||||||
|
* longest title — there's no meaningful "first" in that case. Otherwise
|
||||||
|
* the first ctrl-clicked card wins (state.selectedIds is a Set, which
|
||||||
|
* preserves insertion order).
|
||||||
|
*
|
||||||
|
* Body format: list of all titles as `# heading` lines at the top, then
|
||||||
|
* each card's body separated by `---`. The winner keeps its color, tags,
|
||||||
|
* bucket, due_date, parent_id; the others are deleted.
|
||||||
|
*
|
||||||
|
* The whole operation is bundled into a single undo entry that recreates
|
||||||
|
* the deleted cards verbatim and restores the winner's original body.
|
||||||
|
*/
|
||||||
|
export async function joinSelectedCards() {
|
||||||
|
const ids = [...state.selectedIds].map(String);
|
||||||
|
const cards = ids
|
||||||
|
.map(id => state.cards.find(c => String(c.id) === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (cards.length < 2) return;
|
||||||
|
|
||||||
|
// We need bodies for the merge. /api/cards GET excludes body for
|
||||||
|
// performance, so fetch the full rows for the selected ids in one shot.
|
||||||
|
const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json());
|
||||||
|
const fullById = new Map(fullRes.map(c => [String(c.id), c]));
|
||||||
|
const full = cards.map(c => fullById.get(String(c.id)) || c);
|
||||||
|
|
||||||
|
// Pick winner
|
||||||
|
let winner;
|
||||||
|
if (state.selectionWasBulk) {
|
||||||
|
winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a);
|
||||||
|
} else {
|
||||||
|
winner = full[0]; // first in Set iteration order = first selected
|
||||||
|
}
|
||||||
|
const losers = full.filter(c => c.id !== winner.id);
|
||||||
|
|
||||||
|
// Build merged body: title list, then bodies separated by `---`.
|
||||||
|
// Order: winner first (its body stays at the top after the title list),
|
||||||
|
// then losers in their selection order so the result is deterministic.
|
||||||
|
const ordered = [winner, ...losers];
|
||||||
|
const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n');
|
||||||
|
const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean);
|
||||||
|
let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
|
||||||
|
// Dedupe hashtag mentions across the merged body — first occurrence
|
||||||
|
// wins, subsequent ones get stripped (case-insensitive). Without
|
||||||
|
// this the merged card has every shared tag listed once per source
|
||||||
|
// card, which clutters the body and makes the rendered tag chips
|
||||||
|
// look duplicated to a casual reader (the server's extractTags also
|
||||||
|
// dedupes the tags column, but the body text itself is what the
|
||||||
|
// user actually edits).
|
||||||
|
const seenTags = new Set();
|
||||||
|
mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => {
|
||||||
|
const key = tag.toLowerCase();
|
||||||
|
if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace
|
||||||
|
seenTags.add(key);
|
||||||
|
return match;
|
||||||
|
}).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
// Snapshot for undo BEFORE we mutate anything
|
||||||
|
const winnerPrev = { title: winner.title, body: winner.body || '' };
|
||||||
|
const loserSnapshots = losers.map(c => snapshotCard(c));
|
||||||
|
|
||||||
|
// Apply: PATCH winner with the merged body, DELETE losers.
|
||||||
|
pushUndo(`join ${ordered.length} cards`, async () => {
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev });
|
||||||
|
for (const snap of loserSnapshots) {
|
||||||
|
const created = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: snap.title, body: snap.body, color: snap.color,
|
||||||
|
bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position,
|
||||||
|
}});
|
||||||
|
if (created?.id && snap.due_date) {
|
||||||
|
await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } });
|
||||||
|
for (const c of losers) {
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
|
||||||
export function showContextMenu(x, y, card) {
|
export function showContextMenu(x, y, card) {
|
||||||
const children = getChildren(card.id);
|
const children = getChildren(card.id);
|
||||||
const isParent = children.length > 0;
|
const isParent = children.length > 0;
|
||||||
const isChild = !!card.parent_id;
|
const isChild = !!card.parent_id;
|
||||||
|
// Multi-select join: only offered when the right-clicked card is part
|
||||||
|
// of a selection of ≥2 cards. The action merges them into one.
|
||||||
|
const selected = state.selectedIds;
|
||||||
|
const inMultiSelect = selected.size >= 2 && selected.has(String(card.id));
|
||||||
|
|
||||||
items.innerHTML = `
|
items.innerHTML = `
|
||||||
|
${inMultiSelect ? `<button class="ctx-item" data-action="join"><i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join ${selected.size} cards</span></button><div class="ctx-sep"></div>` : ''}
|
||||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
||||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
||||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
||||||
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Move to date</span></button>` : ''}
|
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Move to date</span></button>` : ''}
|
||||||
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'Due: ' + card.due_date : 'Set due date'}</span></button>
|
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'Due: ' + card.due_date : 'Set due date'}</span></button>
|
||||||
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">Mark done</span></button>` : ''}
|
<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm ${parseTags(card).includes('done') ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${parseTags(card).includes('done') ? 'Unmark done' : 'Mark done'}</span></button>
|
||||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">Save .md</span></button>
|
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">Save .md</span></button>
|
||||||
|
<button class="ctx-item" data-action="copy-text"><i class="ph ph-clipboard-text text-sm"></i><span class="font-pixel text-[10px]">Copy text</span></button>
|
||||||
|
${typeof window._giteaMakeIssueFromCard === 'function' ? `<button class="ctx-item" data-action="make-ticket"><i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">Make ticket</span></button>` : ''}
|
||||||
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">Push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">Push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'Unfavorite' : 'Favorite'}</span></button>
|
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'Unfavorite' : 'Favorite'}</span></button>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
@@ -174,6 +268,11 @@ export function showContextMenu(x, y, card) {
|
|||||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const a = btn.dataset.action;
|
const a = btn.dataset.action;
|
||||||
|
if (a === 'join') {
|
||||||
|
hideContextMenu();
|
||||||
|
await joinSelectedCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
||||||
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
||||||
else if (a === 'duplicate') {
|
else if (a === 'duplicate') {
|
||||||
@@ -184,9 +283,11 @@ export function showContextMenu(x, y, card) {
|
|||||||
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' });
|
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' });
|
||||||
if (!newDate) return;
|
if (!newDate) return;
|
||||||
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||||
await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
const dup1 = await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
if (dup1?.id) state.recentlyCreatedIds?.add(String(dup1.id));
|
||||||
} else {
|
} else {
|
||||||
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
const dup2 = await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
if (dup2?.id) state.recentlyCreatedIds?.add(String(dup2.id));
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
@@ -202,9 +303,16 @@ export function showContextMenu(x, y, card) {
|
|||||||
}
|
}
|
||||||
else if (a === 'due-done') {
|
else if (a === 'due-done') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
// Clear due date and mark as done
|
const isDone = parseTags(card).includes('done');
|
||||||
const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
|
let newBody;
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
|
if (isDone) {
|
||||||
|
newBody = (card.body || '').replace(/(^|\s)#done\b/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n');
|
||||||
|
} else {
|
||||||
|
newBody = (card.body || '').trimEnd() + ((card.body || '').trimEnd() ? ' ' : '') + '#done';
|
||||||
|
}
|
||||||
|
const patch = { body: newBody };
|
||||||
|
if (!isDone && card.due_date) patch.due_date = null;
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: patch });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'due-date') {
|
else if (a === 'due-date') {
|
||||||
@@ -215,6 +323,22 @@ export function showContextMenu(x, y, card) {
|
|||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||||
|
else if (a === 'make-ticket') {
|
||||||
|
hideContextMenu();
|
||||||
|
// Gitea plugin pre-fills the create-issue form from this
|
||||||
|
// card and leaves it open — the user reviews and submits.
|
||||||
|
await window._giteaMakeIssueFromCard?.(card);
|
||||||
|
}
|
||||||
|
else if (a === 'copy-text') {
|
||||||
|
hideContextMenu();
|
||||||
|
let body = card.body || '';
|
||||||
|
// Drop fenced code blocks (incl. slide-image), then markdown structure, then hashtags
|
||||||
|
body = body.replace(/```[\s\S]*?```/g, '');
|
||||||
|
body = stripMd(body);
|
||||||
|
body = body.replace(/(^|\s)#[\w/-]+/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{2,}/g, '\n\n').trim();
|
||||||
|
const text = (card.title || '') + '\n\n' + body;
|
||||||
|
try { await navigator.clipboard.writeText(text); } catch {}
|
||||||
|
}
|
||||||
else if (a === 'push-relay') {
|
else if (a === 'push-relay') {
|
||||||
await openRelayTenantSubmenu(card);
|
await openRelayTenantSubmenu(card);
|
||||||
}
|
}
|
||||||
@@ -232,13 +356,51 @@ export function showContextMenu(x, y, card) {
|
|||||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||||
else if (a === 'delete') {
|
else if (a === 'delete') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
|
const children = getChildren(card.id);
|
||||||
|
const isGroup = children.length > 0;
|
||||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||||
if (needConfirm) {
|
if (isGroup) {
|
||||||
|
// Always confirm when deleting a group — list every child
|
||||||
|
// so the user knows exactly what they're losing.
|
||||||
|
const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n');
|
||||||
|
const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`;
|
||||||
|
const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` });
|
||||||
|
if (!ok) return;
|
||||||
|
} else if (needConfirm) {
|
||||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
const wasSync = !!card.caldav_uid;
|
// Snapshot everything for undo BEFORE deleting
|
||||||
pushCardDelete('delete', snapshotCard(card));
|
const allCards = isGroup ? [card, ...children] : [card];
|
||||||
|
const snapshots = allCards.map(c => snapshotCard(c));
|
||||||
|
const wasSync = allCards.some(c => !!c.caldav_uid);
|
||||||
|
pushUndo(`delete ${isGroup ? 'group' : 'card'} "${card.title}"`, async () => {
|
||||||
|
// Recreate parent first so children can reference it
|
||||||
|
const parent = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: snapshots[0].title, body: snapshots[0].body,
|
||||||
|
color: snapshots[0].color, bucket_id: snapshots[0].bucket_id,
|
||||||
|
position: snapshots[0].position,
|
||||||
|
}});
|
||||||
|
if (parent?.id && snapshots[0].due_date) {
|
||||||
|
await api(`api/cards/${parent.id}`, { method: 'PATCH', body: { due_date: snapshots[0].due_date } });
|
||||||
|
}
|
||||||
|
// Recreate children pointing at the new parent
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
const s = snapshots[i];
|
||||||
|
const child = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: s.title, body: s.body, color: s.color,
|
||||||
|
bucket_id: s.bucket_id, parent_id: parent?.id,
|
||||||
|
position: s.position,
|
||||||
|
}});
|
||||||
|
if (child?.id && s.due_date) {
|
||||||
|
await api(`api/cards/${child.id}`, { method: 'PATCH', body: { due_date: s.due_date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Animate out, then delete children first, then parent
|
||||||
|
const { animateCardRemoval } = await import('./board.js');
|
||||||
|
await animateCardRemoval(allCards.map(c => c.id));
|
||||||
|
for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
if (wasSync) window._triggerSync?.();
|
if (wasSync) window._triggerSync?.();
|
||||||
@@ -284,7 +446,7 @@ function renderRelayTenants(card) {
|
|||||||
const list = cachedRelayTenants?.tenants || [];
|
const list = cachedRelayTenants?.tenants || [];
|
||||||
const self = cachedRelayTenants?.self || '';
|
const self = cachedRelayTenants?.self || '';
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic">no tenants</div>';
|
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic cursor-blink">no tenants</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Self first, then others alphabetical
|
// Self first, then others alphabetical
|
||||||
|
|||||||
@@ -391,6 +391,11 @@ export function closeDetail(options = {}) {
|
|||||||
state._returnToCalendar = false;
|
state._returnToCalendar = false;
|
||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
|
// Return focus to body so board shortcuts work immediately.
|
||||||
|
// Skipped when we just focused the search input (openedFromSearch).
|
||||||
|
if (!state.openedFromSearch && document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDetailCard(id) {
|
export function showDetailCard(id) {
|
||||||
@@ -807,7 +812,7 @@ function renderSplitGroup(cards, label) {
|
|||||||
|
|
||||||
function renderSplitPicks(cards, label) {
|
function renderSplitPicks(cards, label) {
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
||||||
@@ -819,7 +824,7 @@ function renderSplitPicks(cards, label) {
|
|||||||
function renderSplitGroups(groups) {
|
function renderSplitGroups(groups) {
|
||||||
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
||||||
if (!html) {
|
if (!html) {
|
||||||
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
splitPicks.innerHTML = html;
|
splitPicks.innerHTML = html;
|
||||||
|
|||||||
+144
-5
@@ -200,6 +200,8 @@ export function openModal(card = null) {
|
|||||||
currentLine = -1;
|
currentLine = -1;
|
||||||
updateActiveLine();
|
updateActiveLine();
|
||||||
inpTitle.focus();
|
inpTitle.focus();
|
||||||
|
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
||||||
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
inpTitle.addEventListener('input', updatePreview);
|
inpTitle.addEventListener('input', updatePreview);
|
||||||
@@ -354,11 +356,13 @@ body.addEventListener('keyup', updateActiveLine);
|
|||||||
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
||||||
|
|
||||||
export function closeModal({ animate = true } = {}) {
|
export function closeModal({ animate = true } = {}) {
|
||||||
|
const closingId = state.editingId;
|
||||||
if (animate) startCloseMorph();
|
if (animate) startCloseMorph();
|
||||||
else clearMorphGhost();
|
else clearMorphGhost();
|
||||||
hideLinkAc();
|
hideLinkAc();
|
||||||
hideTagAc();
|
hideTagAc();
|
||||||
hideSnippetAc();
|
hideSnippetAc();
|
||||||
|
closeFindBar();
|
||||||
modal.classList.remove('open');
|
modal.classList.remove('open');
|
||||||
state.editingId = null;
|
state.editingId = null;
|
||||||
state.returnToGalleryAfterEdit = false;
|
state.returnToGalleryAfterEdit = false;
|
||||||
@@ -366,6 +370,12 @@ export function closeModal({ animate = true } = {}) {
|
|||||||
state._returnToCalendar = false;
|
state._returnToCalendar = false;
|
||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
|
||||||
|
// Return focus to body so board shortcuts (arrows, bare keys) work
|
||||||
|
// immediately after closing without the user having to click first.
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -406,6 +416,7 @@ async function doSave(close, { animate = true } = {}) {
|
|||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
if (!close) state.editingId = created.id;
|
if (!close) state.editingId = created.id;
|
||||||
state._lastSavedId = created.id;
|
state._lastSavedId = created.id;
|
||||||
|
state.recentlyCreatedIds?.add(String(created.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
@@ -979,15 +990,133 @@ body.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
||||||
|
|
||||||
|
// ── Find / Replace (Ctrl+H / Ctrl+F) ──
|
||||||
|
|
||||||
|
const findBar = $('editor-find-bar');
|
||||||
|
const findInput = $('find-input');
|
||||||
|
const replInput = $('replace-input');
|
||||||
|
const findCount = $('find-count');
|
||||||
|
const replRow = $('replace-row');
|
||||||
|
|
||||||
|
let findMatches = [];
|
||||||
|
let findCurrent = -1;
|
||||||
|
|
||||||
|
export function openFindBar(withReplace = true) {
|
||||||
|
if (!findBar) return;
|
||||||
|
findBar.classList.remove('hidden');
|
||||||
|
replRow?.classList.toggle('hidden', !withReplace);
|
||||||
|
findInput?.focus();
|
||||||
|
if (findInput?.value) runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFindBar() {
|
||||||
|
findBar?.classList.add('hidden');
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (findCount) findCount.textContent = '';
|
||||||
|
body.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFind() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (!needle) { if (findCount) findCount.textContent = ''; return; }
|
||||||
|
const text = body.value;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = lower.indexOf(nLower, pos);
|
||||||
|
if (idx === -1) break;
|
||||||
|
findMatches.push(idx);
|
||||||
|
pos = idx + 1;
|
||||||
|
}
|
||||||
|
if (findCount) findCount.textContent = findMatches.length ? `${findMatches.length} found` : 'no match';
|
||||||
|
if (findMatches.length) jumpTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpTo(idx) {
|
||||||
|
findCurrent = idx;
|
||||||
|
const pos = findMatches[idx];
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
body.focus();
|
||||||
|
body.setSelectionRange(pos, pos + needle.length);
|
||||||
|
if (findCount) findCount.textContent = `${idx + 1} / ${findMatches.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent + 1) % findMatches.length);
|
||||||
|
}
|
||||||
|
function findPrev() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent - 1 + findMatches.length) % findMatches.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOne() {
|
||||||
|
if (findCurrent < 0 || !findMatches.length) return;
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
const pos = findMatches[findCurrent];
|
||||||
|
body.setRangeText(repl, pos, pos + needle.length, 'end');
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
if (findMatches.length && findCurrent >= findMatches.length) findCurrent = 0;
|
||||||
|
if (findMatches.length) jumpTo(findCurrent < 0 ? 0 : findCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
if (!needle) return;
|
||||||
|
// Simple case-insensitive global replace via regex
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
body.value = body.value.replace(new RegExp(escaped, 'gi'), repl);
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
findInput?.addEventListener('input', runFind);
|
||||||
|
findInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
replInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
$('find-next')?.addEventListener('click', findNext);
|
||||||
|
$('find-prev')?.addEventListener('click', findPrev);
|
||||||
|
$('find-close')?.addEventListener('click', closeFindBar);
|
||||||
|
$('replace-one')?.addEventListener('click', replaceOne);
|
||||||
|
$('replace-all')?.addEventListener('click', replaceAll);
|
||||||
|
|
||||||
|
// Ctrl+H = find+replace, Ctrl+F = find only (within editor)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (!modal.classList.contains('open')) return;
|
||||||
|
if ((e.key === 'h' || e.key === 'f') && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// If already open and same mode, toggle off
|
||||||
|
if (!findBar?.classList.contains('hidden')) {
|
||||||
|
const wantReplace = e.key === 'h';
|
||||||
|
const hasReplace = !replRow?.classList.contains('hidden');
|
||||||
|
if (wantReplace === hasReplace) { closeFindBar(); return; }
|
||||||
|
}
|
||||||
|
openFindBar(e.key === 'h');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── File upload (paste + drag-drop) ──
|
// ── File upload (paste + drag-drop) ──
|
||||||
|
|
||||||
async function uploadFile(file) {
|
async function uploadFile(file) {
|
||||||
const isAudio = file.type.startsWith('audio/');
|
const isAudio = file.type.startsWith('audio/');
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
if (!isAudio && !isImage) return;
|
const isPdf = file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
|
||||||
|
if (!isAudio && !isImage && !isPdf) return;
|
||||||
|
|
||||||
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
|
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
|
||||||
const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
|
const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
|
||||||
const pos = body.selectionStart;
|
const pos = body.selectionStart;
|
||||||
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
|
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
|
||||||
updatePreview();
|
updatePreview();
|
||||||
@@ -1000,7 +1129,9 @@ async function uploadFile(file) {
|
|||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
const md = isAudio
|
const md = isPdf
|
||||||
|
? `[📄 ${file.name || data.name}](${data.url})`
|
||||||
|
: isAudio
|
||||||
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
||||||
: ``;
|
: ``;
|
||||||
const idx = body.value.indexOf(placeholder);
|
const idx = body.value.indexOf(placeholder);
|
||||||
@@ -1023,7 +1154,7 @@ body.addEventListener('paste', e => {
|
|||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith('image/') || item.type.startsWith('audio/')) {
|
if (item.type.startsWith('image/') || item.type.startsWith('audio/') || item.type === 'application/pdf') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) uploadFile(file);
|
if (file) uploadFile(file);
|
||||||
@@ -1037,7 +1168,7 @@ body.addEventListener('drop', e => {
|
|||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.type.startsWith('image/') || file.type.startsWith('audio/')) {
|
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type === 'application/pdf') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
uploadFile(file);
|
uploadFile(file);
|
||||||
return;
|
return;
|
||||||
@@ -1045,6 +1176,14 @@ body.addEventListener('drop', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PDF upload toolbar button
|
||||||
|
$('md-upload-pdf')?.addEventListener('click', () => $('md-upload-pdf-file')?.click());
|
||||||
|
$('md-upload-pdf-file')?.addEventListener('change', e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadFile(file);
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
// ── Image resize ──
|
// ── Image resize ──
|
||||||
|
|
||||||
document.addEventListener('mousedown', e => {
|
document.addEventListener('mousedown', e => {
|
||||||
|
|||||||
+115
-17
@@ -1,44 +1,79 @@
|
|||||||
// ── Image gallery ──
|
// ── Assets (images, audio, PDFs) ──
|
||||||
|
|
||||||
import { $, esc, api, getCsrf } from './util.js';
|
import { $, esc, api, getCsrf } from './util.js';
|
||||||
|
|
||||||
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
||||||
|
const PDF_EXT = /\.pdf$/i;
|
||||||
|
const IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
|
||||||
|
function fileType(url) {
|
||||||
|
if (IMAGE_EXT.test(url)) return 'image';
|
||||||
|
if (AUDIO_EXT.test(url)) return 'audio';
|
||||||
|
if (PDF_EXT.test(url)) return 'pdf';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
function isAudio(url) { return AUDIO_EXT.test(url); }
|
function isAudio(url) { return AUDIO_EXT.test(url); }
|
||||||
|
|
||||||
function mediaTile(url, extraClass = '') {
|
function mediaTile(url, extraClass = '') {
|
||||||
if (isAudio(url)) {
|
const type = fileType(url);
|
||||||
|
if (type === 'audio') {
|
||||||
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
|
||||||
<i class="ph ph-music-note text-2xl text-t-dim"></i>
|
<i class="ph ph-music-note text-2xl text-t-dim"></i>
|
||||||
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
|
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
if (type === 'pdf') {
|
||||||
|
const name = url.split('/').pop();
|
||||||
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-3">
|
||||||
|
<i class="ph ph-file-pdf text-3xl text-t-danger"></i>
|
||||||
|
<span class="font-pixel text-[8px] text-t-muted truncate w-full text-center">${esc(name)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
||||||
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeFilter = 'all';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
|
// Cards with full bodies — fetched fresh by openGallery so we don't
|
||||||
|
// depend on state.cards which may have body=null during the two-phase
|
||||||
|
// lazy load (loadCards loads /api/cards/meta first, then batches of 25
|
||||||
|
// with bodies). Without this, most cards look body-less and every file
|
||||||
|
// appears orphaned until the last batch completes.
|
||||||
|
let galleryCards = [];
|
||||||
|
|
||||||
function getReferencedUrls() {
|
function getReferencedUrls() {
|
||||||
const urls = new Set();
|
const urls = new Set();
|
||||||
for (const card of state.cards) {
|
for (const card of galleryCards) {
|
||||||
const body = card.body || '';
|
const body = card.body || '';
|
||||||
let m;
|
let m;
|
||||||
|
// Markdown images: 
|
||||||
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||||
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
||||||
|
// HTML images
|
||||||
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
||||||
|
// Slide-image blocks
|
||||||
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
||||||
while ((m = slideRe.exec(body)) !== null) {
|
while ((m = slideRe.exec(body)) !== null) {
|
||||||
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
|
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||||
if (srcMatch) urls.add(srcMatch[1].trim());
|
if (srcMatch) urls.add(srcMatch[1].trim());
|
||||||
}
|
}
|
||||||
|
// Markdown links to uploads (PDFs, other assets): [text](uploads/...)
|
||||||
|
const linkRe = /\[[^\]]*\]\((uploads\/[^)]+)\)/g;
|
||||||
|
while ((m = linkRe.exec(body)) !== null) urls.add(m[1]);
|
||||||
|
// Plain URL mentions (catches any format we missed above)
|
||||||
|
const plainRe = /uploads\/[a-f0-9]{32}\.[a-z0-9]+/gi;
|
||||||
|
while ((m = plainRe.exec(body)) !== null) urls.add(m[0]);
|
||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCardForUrl(url) {
|
function findCardForUrl(url) {
|
||||||
for (const card of state.cards) {
|
for (const card of galleryCards) {
|
||||||
const body = card.body || '';
|
const body = card.body || '';
|
||||||
if (body.includes(url)) return card;
|
if (body.includes(url)) return card;
|
||||||
}
|
}
|
||||||
@@ -49,7 +84,19 @@ export async function openGallery() {
|
|||||||
const el = $('gallery');
|
const el = $('gallery');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const files = await api('api/uploads');
|
// Fetch both file list and full card bodies in parallel. Using
|
||||||
|
// api('api/cards') (not state.cards) guarantees bodies are present
|
||||||
|
// — state.cards may still be in the two-phase lazy-load with
|
||||||
|
// body=null on most entries.
|
||||||
|
let files = [], cards = [];
|
||||||
|
try {
|
||||||
|
[files, cards] = await Promise.all([
|
||||||
|
api('api/uploads'),
|
||||||
|
api('api/cards'),
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
files = Array.isArray(files) ? files : [];
|
||||||
|
galleryCards = Array.isArray(cards) ? cards : [];
|
||||||
const referenced = getReferencedUrls();
|
const referenced = getReferencedUrls();
|
||||||
const grid = $('gallery-grid');
|
const grid = $('gallery-grid');
|
||||||
const count = $('gallery-count');
|
const count = $('gallery-count');
|
||||||
@@ -66,21 +113,62 @@ export async function openGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = linked.length + orphaned.length;
|
const total = linked.length + orphaned.length;
|
||||||
if (count) count.textContent = `${total} image${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`;
|
if (count) count.textContent = `${total} asset${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`;
|
||||||
|
|
||||||
|
// Tab filter
|
||||||
|
const allFiles = [...orphaned, ...linked];
|
||||||
|
const applyFilter = (filter) => {
|
||||||
|
activeFilter = filter;
|
||||||
|
document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.filter === filter);
|
||||||
|
});
|
||||||
|
let visibleCount = 0;
|
||||||
|
grid.querySelectorAll('.gallery-item').forEach(item => {
|
||||||
|
const url = item.dataset.url || '';
|
||||||
|
const type = fileType(url);
|
||||||
|
const show = filter === 'all' || type === filter;
|
||||||
|
item.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleCount++;
|
||||||
|
});
|
||||||
|
// Hide section headers when no items in that section are visible
|
||||||
|
grid.querySelectorAll('[data-section]:not(.gallery-item)').forEach(hdr => {
|
||||||
|
const section = hdr.dataset.section;
|
||||||
|
const hasVisible = !!grid.querySelector(`.gallery-item[data-section="${section}"]:not([style*="display: none"])`);
|
||||||
|
hdr.style.display = hasVisible ? '' : 'none';
|
||||||
|
});
|
||||||
|
// Show/hide the empty-filter placeholder
|
||||||
|
let placeholder = grid.querySelector('.gallery-filter-empty');
|
||||||
|
if (visibleCount === 0 && total > 0) {
|
||||||
|
if (!placeholder) {
|
||||||
|
placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'gallery-filter-empty col-span-full text-center py-16';
|
||||||
|
const icons = { image: 'ph-image', audio: 'ph-music-note', pdf: 'ph-file-pdf' };
|
||||||
|
const icon = icons[filter] || 'ph-folder-open';
|
||||||
|
placeholder.innerHTML = `<i class="ph ${icon} text-4xl text-t-dim mb-2 block"></i><p class="font-pixel text-[10px] text-t-dim">no ${filter} assets</p>`;
|
||||||
|
grid.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
placeholder.style.display = '';
|
||||||
|
} else if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => {
|
||||||
|
t.onclick = () => applyFilter(t.dataset.filter);
|
||||||
|
});
|
||||||
|
|
||||||
if (!total) {
|
if (!total) {
|
||||||
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
||||||
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
<i class="ph ph-folder-open text-4xl text-t-dim mb-2 block"></i>
|
||||||
<p class="font-pixel text-[10px] text-t-dim">no images</p></div>`;
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no assets</p></div>`;
|
||||||
} else {
|
} else {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (orphaned.length) {
|
if (orphaned.length) {
|
||||||
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2" data-section="orphaned"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
||||||
html += orphaned.map(f => `
|
html += orphaned.map(f => `
|
||||||
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}">
|
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}" data-url="${esc(f.url)}" data-section="orphaned">
|
||||||
<div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
|
<div class="relative">
|
||||||
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">
|
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
|
||||||
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
||||||
@@ -92,9 +180,9 @@ export async function openGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (linked.length) {
|
if (linked.length) {
|
||||||
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3" data-section="linked"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
||||||
html += linked.map(img => `
|
html += linked.map(img => `
|
||||||
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}">
|
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}" data-url="${esc(img.url)}" data-section="linked">
|
||||||
${mediaTile(img.url, 'border-t-border')}
|
${mediaTile(img.url, 'border-t-border')}
|
||||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
|
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -117,13 +205,16 @@ export async function openGallery() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orphan: click to enlarge
|
// Orphan: click to enlarge (images) or open in new tab (PDFs)
|
||||||
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
||||||
item.addEventListener('click', e => {
|
item.addEventListener('click', e => {
|
||||||
if (e.target.closest('button')) return;
|
if (e.target.closest('button')) return;
|
||||||
const name = item.dataset.orphan;
|
const name = item.dataset.orphan;
|
||||||
const f = orphaned.find(o => o.name === name);
|
const f = orphaned.find(o => o.name === name);
|
||||||
if (f && !isAudio(f.url)) openOrphanLightbox(f);
|
if (!f) return;
|
||||||
|
const type = fileType(f.url);
|
||||||
|
if (type === 'pdf') window.open(f.url, '_blank');
|
||||||
|
else if (type === 'image') openOrphanLightbox(f);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +225,11 @@ export async function openGallery() {
|
|||||||
const name = btn.dataset.newOrphan;
|
const name = btn.dataset.newOrphan;
|
||||||
const label = name.replace(/\.[^.]+$/, '');
|
const label = name.replace(/\.[^.]+$/, '');
|
||||||
closeGallery();
|
closeGallery();
|
||||||
window._openModal({ id: null, title: label, body: ``, color: '#38bdf8', bucket_id: null });
|
const type = fileType('uploads/' + name);
|
||||||
|
const body = type === 'pdf'
|
||||||
|
? `[📄 ${name}](uploads/${name})`
|
||||||
|
: ``;
|
||||||
|
window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,6 +260,9 @@ export async function openGallery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the active filter (persists across re-opens within a session)
|
||||||
|
applyFilter(activeFilter);
|
||||||
|
|
||||||
el.classList.add('open');
|
el.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function renderStream() {
|
|||||||
const all = getAllItems();
|
const all = getAllItems();
|
||||||
|
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4">no notifications</div>';
|
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4 cursor-blink">no notifications</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-12
@@ -3,6 +3,7 @@
|
|||||||
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||||
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||||
import { renderMd, stripMd } from './markdown.js';
|
import { renderMd, stripMd } from './markdown.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const overlay = $('search');
|
const overlay = $('search');
|
||||||
const input = $('search-input');
|
const input = $('search-input');
|
||||||
@@ -11,29 +12,74 @@ const footer = $('search-footer');
|
|||||||
const previewEmpty = $('search-preview-empty');
|
const previewEmpty = $('search-preview-empty');
|
||||||
const previewContent = $('search-preview-content');
|
const previewContent = $('search-preview-content');
|
||||||
const bucketFilter = $('search-bucket-filter');
|
const bucketFilter = $('search-bucket-filter');
|
||||||
let active = -1, hits = [];
|
const replaceToggle = $('search-replace-toggle');
|
||||||
|
const replaceRow = $('search-replace-row');
|
||||||
|
const replaceInput = $('search-replace-input');
|
||||||
|
const replaceStatus = $('search-replace-status');
|
||||||
|
const replaceAllBtn = $('search-replace-all');
|
||||||
|
let active = -1, hits = [], replaceMode = false, freshCards = null;
|
||||||
|
|
||||||
export function openSearch() {
|
export function openSearch({ replace = false } = {}) {
|
||||||
window._closeTasks?.();
|
window._closeTasks?.();
|
||||||
input.value = '';
|
input.value = '';
|
||||||
results.innerHTML = '';
|
results.innerHTML = '';
|
||||||
footer.classList.add('hidden');
|
footer.classList.add('hidden');
|
||||||
active = -1;
|
active = -1;
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
|
setReplaceMode(replace);
|
||||||
input.focus();
|
input.focus();
|
||||||
clearPreview();
|
clearPreview();
|
||||||
renderHome();
|
renderHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSearch() { overlay.classList.remove('open'); }
|
async function setReplaceMode(on) {
|
||||||
|
replaceMode = on;
|
||||||
|
replaceRow?.classList.toggle('hidden', !on);
|
||||||
|
replaceToggle?.classList.toggle('active', on);
|
||||||
|
if (on) {
|
||||||
|
replaceInput.value = '';
|
||||||
|
freshCards = await api('api/cards') || [];
|
||||||
|
} else {
|
||||||
|
freshCards = null;
|
||||||
|
}
|
||||||
|
updateReplaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReplaceState() {
|
||||||
|
if (!replaceMode || !replaceAllBtn) return;
|
||||||
|
const q = input.value.trim();
|
||||||
|
const hasRepl = replaceInput?.value.length > 0;
|
||||||
|
const hasMatch = hits.length > 0 && q.length > 0;
|
||||||
|
replaceAllBtn.disabled = !(hasMatch && hasRepl);
|
||||||
|
if (q && hits.length) {
|
||||||
|
let total = 0;
|
||||||
|
const nLower = q.toLowerCase();
|
||||||
|
for (const c of hits) {
|
||||||
|
const body = (c.body || '').toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; total++; pos = i + 1; }
|
||||||
|
}
|
||||||
|
replaceStatus.textContent = total ? `${total} in ${hits.length} card${hits.length !== 1 ? 's' : ''}` : '';
|
||||||
|
} else {
|
||||||
|
replaceStatus.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSearch() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
||||||
|
|
||||||
function getPool() {
|
function getPool() {
|
||||||
|
const cards = (replaceMode && freshCards) ? freshCards : state.cards;
|
||||||
const bv = bucketFilter.value;
|
const bv = bucketFilter.value;
|
||||||
if (!bv) return state.cards;
|
if (!bv) return cards;
|
||||||
if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
|
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
||||||
return state.cards.filter(c => c.bucket_id == bv);
|
return cards.filter(c => c.bucket_id == bv);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -80,12 +126,12 @@ function updatePreview() {
|
|||||||
|
|
||||||
function renderHome() {
|
function renderHome() {
|
||||||
const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
|
const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
|
||||||
if (!recent.length) { results.innerHTML = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel">no cards yet</div>`; return; }
|
if (!recent.length) { results.innerHTML = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel cursor-blink">no cards yet</div>`; return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
|
||||||
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
||||||
hits = recent; active = recent.length ? 0 : -1;
|
hits = recent; active = recent.length ? 0 : -1;
|
||||||
footer.classList.remove('hidden');
|
footer.classList.remove('hidden');
|
||||||
bindHits(); updateActive(); updatePreview();
|
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(query) {
|
function search(query) {
|
||||||
@@ -110,10 +156,10 @@ function search(query) {
|
|||||||
// Tag-only search (no words)
|
// Tag-only search (no words)
|
||||||
if (!words.length) {
|
if (!words.length) {
|
||||||
hits = pool; active = pool.length ? 0 : -1;
|
hits = pool; active = pool.length ? 0 : -1;
|
||||||
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
||||||
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
||||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
|
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState(); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scored = pool.map(c => {
|
const scored = pool.map(c => {
|
||||||
@@ -141,10 +187,10 @@ function search(query) {
|
|||||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
||||||
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
|
||||||
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
|
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
|
||||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview();
|
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hitHtml(card, idx, words) {
|
function hitHtml(card, idx, words) {
|
||||||
@@ -246,3 +292,44 @@ input.addEventListener('keydown', e => {
|
|||||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
||||||
$('search-btn')?.addEventListener('click', openSearch);
|
$('search-btn')?.addEventListener('click', openSearch);
|
||||||
$('search-close')?.addEventListener('click', closeSearch);
|
$('search-close')?.addEventListener('click', closeSearch);
|
||||||
|
replaceToggle?.addEventListener('click', () => setReplaceMode(!replaceMode));
|
||||||
|
replaceInput?.addEventListener('input', updateReplaceState);
|
||||||
|
|
||||||
|
replaceAllBtn?.addEventListener('click', async () => {
|
||||||
|
const needle = input.value.trim();
|
||||||
|
const repl = replaceInput.value;
|
||||||
|
if (!needle || !hits.length) return;
|
||||||
|
let totalOcc = 0;
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
for (const c of hits) {
|
||||||
|
let pos = 0;
|
||||||
|
const body = (c.body || '').toLowerCase();
|
||||||
|
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; totalOcc++; pos = i + 1; }
|
||||||
|
}
|
||||||
|
const ok = await showDialog({
|
||||||
|
title: 'replace_all',
|
||||||
|
message: `Replace ${totalOcc} occurrence${totalOcc !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${hits.length} card${hits.length !== 1 ? 's' : ''}?`,
|
||||||
|
danger: true, okLabel: `Replace ${totalOcc}`,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const freshCards = await api('api/cards') || [];
|
||||||
|
const freshMap = new Map(freshCards.map(c => [c.id, c]));
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(escaped, 'gi');
|
||||||
|
let patched = 0;
|
||||||
|
for (const c of hits) {
|
||||||
|
const fresh = freshMap.get(c.id);
|
||||||
|
const body = fresh?.body || c.body || '';
|
||||||
|
const newBody = body.replace(re, repl);
|
||||||
|
if (newBody !== body) {
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaceStatus.textContent = `done — ${patched} card${patched !== 1 ? 's' : ''} updated`;
|
||||||
|
replaceStatus.style.color = 'var(--t-accent)';
|
||||||
|
replaceAllBtn.disabled = true;
|
||||||
|
await window._reloadAll();
|
||||||
|
search(input.value);
|
||||||
|
setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// ── Central keyboard shortcut registry ──
|
||||||
|
//
|
||||||
|
// Every keyboard shortcut — bare keys, combos, context-specific — is
|
||||||
|
// registered here via registerShortcut(). A single document-level
|
||||||
|
// keydown listener dispatches to the matching handler. The registry
|
||||||
|
// also drives the auto-generated #shortcuts help screen so the UI
|
||||||
|
// and the code always agree on what's available.
|
||||||
|
//
|
||||||
|
// Shortcuts that live on specific DOM elements (editor textarea,
|
||||||
|
// search input, canvas overlay, dialog) stay element-scoped because
|
||||||
|
// they depend on local module state (autocomplete visibility, etc.).
|
||||||
|
// Only document-level shortcuts are centralized here.
|
||||||
|
|
||||||
|
const registry = [];
|
||||||
|
|
||||||
|
const OVERLAY_IDS = ['modal', 'detail', 'search', 'calendar', 'gallery',
|
||||||
|
'settings-overlay', 'help', 'shortcuts', 'email-overlay', 'tasks',
|
||||||
|
'dialog', 'ascii-canvas-overlay'];
|
||||||
|
|
||||||
|
function isOpen(id) {
|
||||||
|
return document.getElementById(id)?.classList.contains('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function noOverlay() {
|
||||||
|
return !OVERLAY_IDS.some(isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyFocused() {
|
||||||
|
return document.activeElement === document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a keyboard shortcut.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.key — e.key value to match (e.g. 'a', 'Enter', 'Escape', '?')
|
||||||
|
* @param {boolean} [opts.ctrl] — require Ctrl/Cmd
|
||||||
|
* @param {boolean} [opts.shift] — require Shift
|
||||||
|
* @param {boolean} [opts.alt] — require Alt
|
||||||
|
* @param {function} [opts.match] — custom predicate (e) => bool; overrides key/ctrl/shift/alt
|
||||||
|
* @param {function} [opts.guard] — extra runtime check; shortcut only fires when guard() is truthy
|
||||||
|
* @param {number} [opts.priority] — higher = checked first (default 0)
|
||||||
|
* @param {boolean} [opts.stop] — call e.stopPropagation() + e.stopImmediatePropagation()
|
||||||
|
* @param {string} opts.section — help-screen section header
|
||||||
|
* @param {string} opts.label — human description for the help screen
|
||||||
|
* @param {string} [opts.keys] — display string for the help screen (e.g. '⌃ S'); auto-built if omitted
|
||||||
|
* @param {boolean} [opts.hidden] — true to suppress from the help screen
|
||||||
|
* @param {function} opts.handler — (e: KeyboardEvent) => void
|
||||||
|
*/
|
||||||
|
export function registerShortcut(opts) {
|
||||||
|
registry.push({
|
||||||
|
key: opts.key ?? null,
|
||||||
|
ctrl: !!opts.ctrl,
|
||||||
|
shift: !!opts.shift,
|
||||||
|
alt: !!opts.alt,
|
||||||
|
match: opts.match ?? null,
|
||||||
|
guard: opts.guard ?? null,
|
||||||
|
priority: opts.priority ?? 0,
|
||||||
|
stop: !!opts.stop,
|
||||||
|
section: opts.section ?? 'misc',
|
||||||
|
label: opts.label ?? '',
|
||||||
|
keys: opts.keys ?? null,
|
||||||
|
hidden: !!opts.hidden,
|
||||||
|
handler: opts.handler,
|
||||||
|
});
|
||||||
|
// Keep sorted by priority desc so the dispatch loop can short-circuit.
|
||||||
|
registry.sort((a, b) => b.priority - a.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the full registry for the auto-generated help screen. */
|
||||||
|
export function getShortcuts() {
|
||||||
|
return registry.filter(s => !s.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility guards for use in shortcut definitions. */
|
||||||
|
export const guards = {
|
||||||
|
board: () => bodyFocused() && noOverlay(),
|
||||||
|
editor: () => isOpen('modal'),
|
||||||
|
detail: () => isOpen('detail'),
|
||||||
|
search: () => isOpen('search'),
|
||||||
|
email: () => isOpen('email-overlay'),
|
||||||
|
canvas: () => isOpen('ascii-canvas-overlay'),
|
||||||
|
noOverlay,
|
||||||
|
bodyFocused,
|
||||||
|
isOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dispatcher ──
|
||||||
|
|
||||||
|
function isEditableTarget() {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const inEditable = isEditableTarget();
|
||||||
|
|
||||||
|
for (const s of registry) {
|
||||||
|
// Key match
|
||||||
|
let matched;
|
||||||
|
if (s.match) {
|
||||||
|
matched = s.match(e);
|
||||||
|
} else {
|
||||||
|
if (s.key !== e.key) continue;
|
||||||
|
if (s.ctrl !== (e.ctrlKey || e.metaKey)) continue;
|
||||||
|
if (s.shift !== e.shiftKey) continue;
|
||||||
|
if (s.alt !== e.altKey) continue;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
// Suppress non-modifier shortcuts when focus is in an editable field
|
||||||
|
// (allow Escape and function keys like F1 through)
|
||||||
|
if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape' && !e.key.startsWith('F')) continue;
|
||||||
|
|
||||||
|
// Guard
|
||||||
|
if (s.guard && !s.guard()) continue;
|
||||||
|
|
||||||
|
// Fire
|
||||||
|
e.preventDefault();
|
||||||
|
if (s.stop) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
s.handler(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto-generated help screen ──
|
||||||
|
|
||||||
|
/** Render the #shortcuts overlay content from the registry.
|
||||||
|
* Called once after all modules have registered their shortcuts
|
||||||
|
* (typically at the end of app.js boot). */
|
||||||
|
export function renderShortcutHelp() {
|
||||||
|
const container = document.querySelector('#shortcuts .shortcut-help-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const visible = getShortcuts();
|
||||||
|
// Group by section, preserve registration order within each section
|
||||||
|
const sections = new Map();
|
||||||
|
for (const s of visible) {
|
||||||
|
if (!sections.has(s.section)) sections.set(s.section, []);
|
||||||
|
sections.get(s.section).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML — each section is a block with a header + table.
|
||||||
|
let html = '';
|
||||||
|
for (const [name, shortcuts] of sections) {
|
||||||
|
html += `<section>
|
||||||
|
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">${esc(name)}</h3>
|
||||||
|
<table class="shortcut-table"><tbody>`;
|
||||||
|
for (const s of shortcuts) {
|
||||||
|
const keysHtml = s.keys
|
||||||
|
? s.keys // pre-formatted HTML (kbd tags etc.)
|
||||||
|
: `<kbd>${esc(s.key || '?')}</kbd>`;
|
||||||
|
html += `<tr><td>${keysHtml}</td><td>${esc(s.label)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table></section>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
@@ -7,8 +7,12 @@ export const state = {
|
|||||||
cards: [],
|
cards: [],
|
||||||
buckets: [],
|
buckets: [],
|
||||||
|
|
||||||
// Filters & view
|
// Filters & view — activeBucket is persisted across reloads via
|
||||||
activeBucket: 'all',
|
// localStorage. Numeric ids are stringified; the special filters
|
||||||
|
// ('all', 'current', 'favorites', 'journal', 'none') are stored
|
||||||
|
// as-is. Validity is re-checked after buckets load — see
|
||||||
|
// ensureActiveBucketValid() below.
|
||||||
|
activeBucket: localStorage.getItem('hyprcrd-active-bucket') || 'all',
|
||||||
activeTags: [],
|
activeTags: [],
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||||
@@ -23,6 +27,21 @@ export const state = {
|
|||||||
|
|
||||||
// Multi-select
|
// Multi-select
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
|
// True when at least one bulk gesture (rubber-band) participated in
|
||||||
|
// building the current selection. Used by the join/merge action to
|
||||||
|
// pick the title strategy: false → first selected wins, true →
|
||||||
|
// longest title wins (since "first" isn't meaningful for bulk).
|
||||||
|
selectionWasBulk: false,
|
||||||
|
// Card ids that have arrived since the user last entered the "all"
|
||||||
|
// view. Drives the unread dot on the all bucket-tab and the brief
|
||||||
|
// accent pulse on the cards themselves once the user looks at them.
|
||||||
|
// Updated by board.js renderBoard() — see the diff there.
|
||||||
|
unseenCardIds: new Set(),
|
||||||
|
// Ids of cards the user just created locally (editor save, quick
|
||||||
|
// capture, duplicate, undo-recreate). These get a single-pulse (1x)
|
||||||
|
// instead of the triple-pulse (3x) that relay-synced cards get.
|
||||||
|
// Consumed by renderBoard's diff — once used, the id is removed.
|
||||||
|
recentlyCreatedIds: new Set(),
|
||||||
|
|
||||||
// Detail / editor
|
// Detail / editor
|
||||||
editingId: null,
|
editingId: null,
|
||||||
@@ -47,6 +66,28 @@ export const state = {
|
|||||||
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
||||||
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
||||||
|
|
||||||
|
const SPECIAL_BUCKETS = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||||
|
|
||||||
|
/** Set state.activeBucket and persist it to localStorage so it survives
|
||||||
|
* page reloads. Use this instead of `state.activeBucket = …` everywhere
|
||||||
|
* except tests (tests reset directly to avoid touching localStorage). */
|
||||||
|
export function setActiveBucket(value) {
|
||||||
|
state.activeBucket = value;
|
||||||
|
if (value == null) localStorage.removeItem('hyprcrd-active-bucket');
|
||||||
|
else localStorage.setItem('hyprcrd-active-bucket', String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from loadBuckets after the fresh bucket list arrives. If the
|
||||||
|
* persisted activeBucket points to a bucket id that no longer exists
|
||||||
|
* (deleted from another tab, removed in import, etc.), fall back to
|
||||||
|
* 'all'. Special filters are always valid. */
|
||||||
|
export function ensureActiveBucketValid() {
|
||||||
|
const cur = state.activeBucket;
|
||||||
|
if (!cur || SPECIAL_BUCKETS.has(cur)) return;
|
||||||
|
const exists = state.buckets.some(b => String(b.id) === String(cur));
|
||||||
|
if (!exists) setActiveBucket('all');
|
||||||
|
}
|
||||||
|
|
||||||
export const CARD_COLORS = [
|
export const CARD_COLORS = [
|
||||||
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
||||||
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
||||||
|
|||||||
@@ -232,7 +232,10 @@ function saveUserPresets(p) {
|
|||||||
function refreshPresetSelect() {
|
function refreshPresetSelect() {
|
||||||
const sel = $('design-preset-select');
|
const sel = $('design-preset-select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const cur = sel.value;
|
// localStorage wins over sel.value: the <select> survives settings
|
||||||
|
// close/reopen, so its lingering value would otherwise mask the
|
||||||
|
// persisted selection on the next refresh.
|
||||||
|
const cur = localStorage.getItem('hyprcrd-design-active-preset') || sel.value || '';
|
||||||
sel.innerHTML = '';
|
sel.innerHTML = '';
|
||||||
const builtinGroup = document.createElement('optgroup');
|
const builtinGroup = document.createElement('optgroup');
|
||||||
builtinGroup.label = 'built-in';
|
builtinGroup.label = 'built-in';
|
||||||
@@ -254,7 +257,15 @@ function refreshPresetSelect() {
|
|||||||
}
|
}
|
||||||
sel.appendChild(userGroup);
|
sel.appendChild(userGroup);
|
||||||
}
|
}
|
||||||
if (cur) sel.value = cur;
|
if (cur && sel.querySelector(`option[value="${CSS.escape(cur)}"]`)) sel.value = cur;
|
||||||
|
// Disable delete button when a built-in preset is selected
|
||||||
|
const delBtn = $('design-preset-delete');
|
||||||
|
if (delBtn) {
|
||||||
|
const isUser = sel.value?.startsWith('user:');
|
||||||
|
delBtn.disabled = !isUser;
|
||||||
|
delBtn.style.opacity = isUser ? '' : '0.35';
|
||||||
|
delBtn.style.cursor = isUser ? '' : 'not-allowed';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedPreset() {
|
function getSelectedPreset() {
|
||||||
@@ -274,6 +285,8 @@ function loadSelectedPreset() {
|
|||||||
syncControlsFromPending();
|
syncControlsFromPending();
|
||||||
commitDesign();
|
commitDesign();
|
||||||
renderThemes();
|
renderThemes();
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (sel?.value) localStorage.setItem('hyprcrd-design-active-preset', sel.value);
|
||||||
}
|
}
|
||||||
$('design-preset-select')?.addEventListener('change', loadSelectedPreset);
|
$('design-preset-select')?.addEventListener('change', loadSelectedPreset);
|
||||||
|
|
||||||
@@ -288,6 +301,7 @@ $('design-preset-save')?.addEventListener('click', () => {
|
|||||||
refreshPresetSelect();
|
refreshPresetSelect();
|
||||||
const sel = $('design-preset-select');
|
const sel = $('design-preset-select');
|
||||||
if (sel) sel.value = 'user:' + name;
|
if (sel) sel.value = 'user:' + name;
|
||||||
|
localStorage.setItem('hyprcrd-design-active-preset', 'user:' + name);
|
||||||
const status = $('design-status');
|
const status = $('design-status');
|
||||||
if (status) { status.textContent = `preset "${name}" saved`; status.style.color = 'var(--t-accent)'; }
|
if (status) { status.textContent = `preset "${name}" saved`; status.style.color = 'var(--t-accent)'; }
|
||||||
});
|
});
|
||||||
@@ -299,6 +313,9 @@ $('design-preset-delete')?.addEventListener('click', () => {
|
|||||||
const presets = loadUserPresets();
|
const presets = loadUserPresets();
|
||||||
delete presets[name];
|
delete presets[name];
|
||||||
saveUserPresets(presets);
|
saveUserPresets(presets);
|
||||||
|
if (localStorage.getItem('hyprcrd-design-active-preset') === 'user:' + name) {
|
||||||
|
localStorage.removeItem('hyprcrd-design-active-preset');
|
||||||
|
}
|
||||||
refreshPresetSelect();
|
refreshPresetSelect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,21 +66,29 @@ export function pushBatch(label, patches) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toast hint
|
// Toast hint
|
||||||
|
//
|
||||||
|
// Centering note: tailwind v4 compiles `-translate-x-1/2` to the *new*
|
||||||
|
// CSS `translate` property, which then stacks with any inline `transform`
|
||||||
|
// we set — the box ended up shifted ~ -100% (utility translate + JS
|
||||||
|
// transform translate both applied) and looked left-anchored. Fix: drop
|
||||||
|
// the tailwind translate utility and centre purely via the modern
|
||||||
|
// `translate` property in inline styles, which is one source of truth.
|
||||||
let hintTimer = null;
|
let hintTimer = null;
|
||||||
function showHint(text) {
|
function showHint(text) {
|
||||||
let el = document.getElementById('undo-hint');
|
let el = document.getElementById('undo-hint');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
el.id = 'undo-hint';
|
el.id = 'undo-hint';
|
||||||
el.className = 'fixed bottom-16 left-1/2 -translate-x-1/2 z-[95] bg-t-surface border border-t-border px-4 py-2 text-xs font-pixel text-t-muted shadow-lg transition-all';
|
el.className = 'fixed bottom-16 left-1/2 z-[95] bg-t-surface border border-t-border px-4 py-2 text-xs font-pixel text-t-muted shadow-lg transition-all';
|
||||||
|
el.style.translate = '-50% 0';
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
el.textContent = text + ' (ctrl+z undo)';
|
el.textContent = text + ' (ctrl+z undo)';
|
||||||
el.style.opacity = '1';
|
el.style.opacity = '1';
|
||||||
el.style.transform = 'translateX(-50%) translateY(0)';
|
el.style.translate = '-50% 0';
|
||||||
clearTimeout(hintTimer);
|
clearTimeout(hintTimer);
|
||||||
hintTimer = setTimeout(() => {
|
hintTimer = setTimeout(() => {
|
||||||
el.style.opacity = '0';
|
el.style.opacity = '0';
|
||||||
el.style.transform = 'translateX(-50%) translateY(8px)';
|
el.style.translate = '-50% 8px';
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,17 @@
|
|||||||
");
|
");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
||||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
||||||
|
// Read receipts for outgoing messages: `delivered` = recipient polled
|
||||||
|
// (single check), `seen` = recipient opened the thread (double check).
|
||||||
|
// Both filled in via /api/chat/sent receipt-poll on the relay side.
|
||||||
|
$cols = $db->query("PRAGMA table_info(chat_messages)")->fetchAll();
|
||||||
|
$colNames = array_column($cols, 'name');
|
||||||
|
if (!in_array('delivered', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!in_array('seen', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||||
$db->exec("
|
$db->exec("
|
||||||
CREATE TABLE IF NOT EXISTS chat_state (
|
CREATE TABLE IF NOT EXISTS chat_state (
|
||||||
@@ -115,7 +126,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
||||||
$stmt->execute(['p' => $peer]);
|
$stmt->execute(['p' => $peer]);
|
||||||
chatJson(['messages' => $stmt->fetchAll()]);
|
chatJson(['messages' => $stmt->fetchAll()]);
|
||||||
}
|
}
|
||||||
@@ -164,10 +175,12 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$resData = json_decode($response, true);
|
$resData = json_decode($response, true);
|
||||||
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||||
|
|
||||||
// Store the outgoing message locally so the UI can render it immediately
|
// Store the outgoing message locally so the UI can render it
|
||||||
|
// immediately. relay_id is the relay's autoincrement id; the
|
||||||
|
// receipt-poll matches against it to update delivered/seen state.
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "out", :b, NULL, datetime("now"), 1)')
|
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)')
|
||||||
->execute(['p' => $peer, 'b' => $body]);
|
->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]);
|
||||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +231,58 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
}
|
}
|
||||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||||
|
|
||||||
chatJson(['ok' => true, 'new' => $newCount]);
|
// ── Receipt poll for our SENT messages ──────────────────
|
||||||
|
// Pulls fetched/seen state for everything we've sent. Updates
|
||||||
|
// local outgoing rows by relay_id. Uses an independent `since`
|
||||||
|
// cursor so we keep getting state changes even on rows we
|
||||||
|
// already saw before.
|
||||||
|
$sentSince = (int)chatGetState('last_sent_relay_id', 0);
|
||||||
|
$ch2 = curl_init(rtrim($r['url'], '/') . '/api/chat/sent?since=' . $sentSince);
|
||||||
|
curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$resp2 = curl_exec($ch2);
|
||||||
|
$st2 = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch2);
|
||||||
|
$receiptUpdates = 0;
|
||||||
|
if ($st2 === 200) {
|
||||||
|
$sentData = json_decode($resp2, true) ?: [];
|
||||||
|
$receipts = $sentData['receipts'] ?? [];
|
||||||
|
$sentMaxId = (int)($sentData['max_id'] ?? $sentSince);
|
||||||
|
$upd = $db->prepare('UPDATE chat_messages SET delivered = :d, seen = :s WHERE relay_id = :rid AND direction = "out"');
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
$upd->execute([
|
||||||
|
'd' => (int)($rcpt['fetched'] ?? 0),
|
||||||
|
's' => (int)($rcpt['seen'] ?? 0),
|
||||||
|
'rid' => (int)$rcpt['id'],
|
||||||
|
]);
|
||||||
|
$receiptUpdates += $upd->rowCount();
|
||||||
|
}
|
||||||
|
// Cursor only advances when EVERY row's state is final (seen=1).
|
||||||
|
// Otherwise we need to keep re-polling those rows for updates.
|
||||||
|
// Cheap implementation: if every row in this batch is seen, bump
|
||||||
|
// cursor to maxId; else keep polling from the lowest non-seen id.
|
||||||
|
$allSeen = true;
|
||||||
|
$minPending = $sentMaxId;
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
if ((int)($rcpt['seen'] ?? 0) !== 1) {
|
||||||
|
$allSeen = false;
|
||||||
|
if ((int)$rcpt['id'] - 1 < $minPending) $minPending = (int)$rcpt['id'] - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($allSeen && $sentMaxId > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $sentMaxId);
|
||||||
|
} else if (!$allSeen && $minPending > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $minPending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatJson(['ok' => true, 'new' => $newCount, 'receipts' => $receiptUpdates]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/read' && $method === 'POST') {
|
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||||
@@ -228,6 +292,28 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
||||||
->execute(['p' => $peer]);
|
->execute(['p' => $peer]);
|
||||||
|
|
||||||
|
// Tell the relay this peer's messages have been seen by us so the
|
||||||
|
// peer's next /api/chat/sent poll picks up the double-check state.
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if ($r['url'] && $r['user'] && $r['key']) {
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/seen');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['peer' => $peer]),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
// Failures are silent — the user already saw the messages
|
||||||
|
// locally, the relay state is just lagging.
|
||||||
|
}
|
||||||
chatJson(['ok' => true]);
|
chatJson(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border: 1px solid var(--t-border);
|
border: 1px solid var(--t-border);
|
||||||
|
/* Pick up the user's card-radius design token so chat bubbles look
|
||||||
|
consistent with the rest of the surface (sharp / rounded / pill). */
|
||||||
|
border-radius: var(--ui-radius-card, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.in {
|
.chat-bubble.in {
|
||||||
@@ -192,11 +195,25 @@
|
|||||||
color: var(--t-dim);
|
color: var(--t-dim);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-family: var(--t-font-pixel);
|
font-family: var(--t-font-pixel);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
||||||
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Read receipts: clock = sent (relay not yet acked), single check =
|
||||||
|
delivered (recipient polled), double check = seen (recipient opened
|
||||||
|
the thread). Single/double share the same icon size as the timestamp. */
|
||||||
|
.chat-receipt {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.chat-receipt.pending { color: var(--t-dim); }
|
||||||
|
.chat-receipt.delivered { color: var(--t-muted); }
|
||||||
|
.chat-receipt.seen { color: var(--t-accent); }
|
||||||
|
|
||||||
#chat-thread-form {
|
#chat-thread-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -332,3 +349,10 @@
|
|||||||
#chat-footer-btn.blinking {
|
#chat-footer-btn.blinking {
|
||||||
animation: chatBlink 1.2s ease-in-out infinite;
|
animation: chatBlink 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
/* Ignore the global anim-speed override — the unread blink has a
|
||||||
|
deliberate cadence that becomes an unreadable strobe when sped up. */
|
||||||
|
body.anim-fast #chat-footer-btn.blinking,
|
||||||
|
body.anim-slow #chat-footer-btn.blinking {
|
||||||
|
animation: chatBlink 1.2s ease-in-out infinite !important;
|
||||||
|
animation-duration: 1.2s !important;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div id="chat-list" class="chat-list"></div>
|
<div id="chat-list" class="chat-list"></div>
|
||||||
<div id="chat-empty" class="hidden chat-empty">
|
<div id="chat-empty" class="hidden chat-empty">
|
||||||
<i class="ph ph-chats text-2xl text-t-dim mb-2"></i>
|
<i class="ph ph-chats text-2xl text-t-dim mb-2"></i>
|
||||||
<p class="font-pixel text-[10px] text-t-dim">no conversations yet</p>
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no conversations yet</p>
|
||||||
<button id="chat-empty-start" class="mt-3 px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Start chat</button>
|
<button id="chat-empty-start" class="mt-3 px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Start chat</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,9 +231,18 @@ function renderThread() {
|
|||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
wrap.innerHTML = currentMessages.map(m => {
|
wrap.innerHTML = currentMessages.map(m => {
|
||||||
const cls = m.direction === 'out' ? 'out' : 'in';
|
const cls = m.direction === 'out' ? 'out' : 'in';
|
||||||
|
// Read receipts for outgoing messages: single check = recipient
|
||||||
|
// polled (delivered), double check = recipient opened the thread
|
||||||
|
// (seen). Incoming messages get no icon.
|
||||||
|
let receipt = '';
|
||||||
|
if (m.direction === 'out') {
|
||||||
|
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
|
||||||
|
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
||||||
|
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}</div>
|
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
// Scroll to bottom
|
// Scroll to bottom
|
||||||
@@ -307,7 +316,7 @@ async function openPickView() {
|
|||||||
const self = recipientCache.self;
|
const self = recipientCache.self;
|
||||||
const others = tenants.filter(t => t.username !== self);
|
const others = tenants.filter(t => t.username !== self);
|
||||||
if (!others.length) {
|
if (!others.length) {
|
||||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">no other users on the relay</div>';
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = others.map(t => `
|
list.innerHTML = others.map(t => `
|
||||||
@@ -330,6 +339,7 @@ async function pollOnce() {
|
|||||||
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
const newCount = res.new || 0;
|
const newCount = res.new || 0;
|
||||||
|
const receiptUpdates = res.receipts || 0;
|
||||||
if (newCount > 0) {
|
if (newCount > 0) {
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
// If we're inside a thread for any peer, refresh + auto-read it
|
// If we're inside a thread for any peer, refresh + auto-read it
|
||||||
@@ -340,6 +350,10 @@ async function pollOnce() {
|
|||||||
}
|
}
|
||||||
// Blink if the flyout is closed
|
// Blink if the flyout is closed
|
||||||
if (!isOpen()) startBlink();
|
if (!isOpen()) startBlink();
|
||||||
|
} else if (receiptUpdates > 0 && isOpen() && currentPeer) {
|
||||||
|
// No new incoming, but receipts (delivered/seen) changed for
|
||||||
|
// our outgoing — refresh the thread so the check icons update.
|
||||||
|
await loadThread(currentPeer);
|
||||||
} else if (initialPollDone === false) {
|
} else if (initialPollDone === false) {
|
||||||
// First poll: refresh badges in case we have unread from a previous session
|
// First poll: refresh badges in case we have unread from a previous session
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# collab-buckets plugin
|
||||||
|
|
||||||
|
Collaborative buckets shared between relay tenants. Each bucket has an owner
|
||||||
|
and a set of members; membership changes and metadata updates propagate
|
||||||
|
through the existing relay. Collab buckets are **first-class rows in the
|
||||||
|
normal bucket bar** — the plugin just owns the sync plus an unread
|
||||||
|
indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.
|
||||||
|
|
||||||
|
**Phase 1 through 5 shipped.** Metadata + membership sync, bucket-bar
|
||||||
|
integration, full card sync (create / edit / delete, LWW conflict
|
||||||
|
resolution with local Conflicts-bucket stashing, encrypted round-trip
|
||||||
|
via the relay's decrypt-reencrypt hop), advisory per-card edit locks
|
||||||
|
with a 🔒 badge on cards another user is currently editing,
|
||||||
|
bucket-ownership transfer through a members & owner modal, and the
|
||||||
|
Phase 5 hardening pass: **"Make collaborative…"** upgrade path for
|
||||||
|
existing local buckets, **undo** for local bucket delete/leave,
|
||||||
|
**resilient create** via a local outbox that queues intents when the
|
||||||
|
relay is unreachable and retries on the next poll, and
|
||||||
|
**rate-limiting** on all collab endpoints. Remaining nice-to-haves
|
||||||
|
(position propagation, presence, read receipts, inline member picker,
|
||||||
|
owner approval flow) are in `TODO.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Create a collab bucket** from the normal `+ Bucket` button in the
|
||||||
|
bucket bar. The plugin intercepts the button click (via
|
||||||
|
`window._collabBucketsCreateDialog`) and shows a modal with an optional
|
||||||
|
"collaborative" checkbox. Unchecked → a regular local bucket, same as
|
||||||
|
before. Checked → reveals a member picker listing all other relay
|
||||||
|
tenants, and the submit path goes through `/api/collab-buckets` instead
|
||||||
|
of the core `/api/buckets`.
|
||||||
|
- **See other members' buckets** appear automatically in the bucket bar.
|
||||||
|
Every 30 seconds the plugin polls the relay for a full resync; any new
|
||||||
|
or updated bucket the current user is a member of is mirrored into the
|
||||||
|
main `buckets` table, so the core bar renders it alongside everything
|
||||||
|
else.
|
||||||
|
- **Collab tabs are visually distinguished**: the folder icon is swapped
|
||||||
|
for `ph-users-three`, there's a subtle left-edge accent stripe, and the
|
||||||
|
right-click menu's rename / color / delete actions route through the
|
||||||
|
plugin endpoints so changes reach the relay and other members.
|
||||||
|
- **Unread state**: brand-new buckets and ones whose `updated_at` exceeds
|
||||||
|
the local `last_seen_at` show a red dot top-right of the tab and pulse 3
|
||||||
|
times on the next render. Clicking the tab triggers `mark-seen` which
|
||||||
|
clears the dot/pulse immediately and persists to the plugin backend.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two places store bucket state locally:
|
||||||
|
|
||||||
|
1. **Main `buckets` table** (core) — gains a `collab_uid` column so collab
|
||||||
|
buckets are indistinguishable from local buckets in the bucket bar,
|
||||||
|
bucket selects, card-to-bucket joins, etc. `collab_uid` is unique and
|
||||||
|
NULL for regular local buckets.
|
||||||
|
2. **Plugin `collab_buckets` table** — holds collab-only metadata
|
||||||
|
(`members_json`, `owner_username`, `updated_at`, `last_seen_at`) that
|
||||||
|
the main table doesn't need. On every poll the plugin keeps both
|
||||||
|
tables in sync.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───── Browser ─────┐ ┌──── Relay ────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ bucket-bar (core) │ │ │
|
||||||
|
│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │
|
||||||
|
│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │
|
||||||
|
│ │ collab_uid │ ───────────────────────────▶ │ │
|
||||||
|
│ │ │ │ collab_ │
|
||||||
|
│ collab-buckets.js │ POST /api/collab-buckets │ buckets │
|
||||||
|
│ ▲ observer │ /poll (every 30s) │ │
|
||||||
|
│ │ MutationObs │ ◀─────────────────────────── │ collab_ │
|
||||||
|
│ │ + delegation │ full resync │ bucket_ │
|
||||||
|
│ ▼ │ │ members │
|
||||||
|
│ backend.php │ │ │
|
||||||
|
│ ├─ collab_buckets│ │ │
|
||||||
|
│ │ plugin table │ │ │
|
||||||
|
│ └─ upserts/ │ │ │
|
||||||
|
│ deletes rows │ │ │
|
||||||
|
│ in main │ │ │
|
||||||
|
│ buckets table │ │ │
|
||||||
|
└───────────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Relay is source of truth** for bucket metadata + membership. The two
|
||||||
|
local tables are mirrors that together add `last_seen_at` (unread state,
|
||||||
|
user-specific, never synced).
|
||||||
|
- **Full resync every 30 s.** Locally cached buckets that are no longer
|
||||||
|
returned by the relay for this user = we were kicked (or the bucket was
|
||||||
|
deleted); both tables drop them and any cards referencing them fall back
|
||||||
|
to unsorted via `ON DELETE SET NULL`.
|
||||||
|
- **LWW per bucket**: every patch bumps `updated_at` on the relay. The
|
||||||
|
local poll only re-saves rows whose `updated_at` actually changed,
|
||||||
|
otherwise it's a no-op and `last_seen_at` is untouched so unread state
|
||||||
|
survives unrelated polls.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `plugin.json` | Plugin manifest (auto-enabled unless listed in `data/plugins.json`) |
|
||||||
|
| `backend.php` | Plugin backend: `collab_buckets` plugin table, relay proxy, full-resync poll, mark-seen, + `collabUpsertMainBucket` / `collabDeleteMainBucket` helpers that keep the core `buckets` table in sync |
|
||||||
|
| `collab-buckets.js` | Frontend: polling loop, exposes `window._collabBucketsCreateDialog` for the core `+ Bucket` button, MutationObserver on `#bucket-bar` to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for `collab_uid` buckets |
|
||||||
|
| `collab-buckets.css` | Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling |
|
||||||
|
| `README.md` | This file |
|
||||||
|
| `TODO.md` | Deferred features + Phase 3 roadmap |
|
||||||
|
|
||||||
|
### Core-side touchpoints (not in this directory)
|
||||||
|
|
||||||
|
| File | What changes |
|
||||||
|
|---|---|
|
||||||
|
| `src/Database.php` | Migration adds `buckets.collab_uid TEXT` + a unique partial index |
|
||||||
|
| `public/index.php` | `api/buckets` GET returns `collab_uid` (via `SELECT *`). `PATCH` only allows `position` on collab buckets (name/color must go through the plugin so other members see them). `DELETE` refuses collab buckets with a 400 pointing to `/api/collab-buckets/{uid}` |
|
||||||
|
| `public/js/app.js` | Exposes `window.state = state` so the plugin's observer can read `state.buckets` to resolve `collab_uid` per tab |
|
||||||
|
| `public/js/modules/buckets.js` | `+ Bucket` click checks `window._collabBucketsCreateDialog` before falling back to the simple dialog. Rename / color / delete actions check `bucket.collab_uid` and route through `window._collabBucketsPatch` / `_collabBucketsDelete` when set |
|
||||||
|
|
||||||
|
Relay-side files (not in this directory, part of the main relay deployment):
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `relay/inc/bootstrap.php` | Adds `collab_buckets` + `collab_bucket_members` tables (auto-migrated on first boot) |
|
||||||
|
| `relay/inc/routes/collab-buckets.php` | `POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]` |
|
||||||
|
| `relay/index.php` | `require`s the new route file after `chat.php` |
|
||||||
|
|
||||||
|
## API (local)
|
||||||
|
|
||||||
|
All routes live under `/api/collab-buckets` and are registered via
|
||||||
|
`PluginManager::instance()->onApiRoute()`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/collab-buckets` | List local mirror. Each row has `has_unread` computed from `last_seen_at` vs `updated_at`. |
|
||||||
|
| `POST` | `/api/collab-buckets` | `{name, color, members[]}` — generates UID, pushes to relay, mirrors local. The creator's own `last_seen_at = now` so the tile isn't unread for them. |
|
||||||
|
| `PATCH` | `/api/collab-buckets/{uid}` | `{name?, color?, members?}` — owner-only, proxied to relay. Local row updates on next poll. |
|
||||||
|
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → delete; member → leave. Relay decides based on the authenticated caller. |
|
||||||
|
| `POST` | `/api/collab-buckets/poll` | Full resync. Drops stale local rows, upserts changed remote rows, preserves `last_seen_at`. |
|
||||||
|
| `POST` | `/api/collab-buckets/mark-seen` | `{uid}` — sets `last_seen_at = now` so the dot / pulse clear. |
|
||||||
|
| `GET` | `/api/collab-buckets/relay-tenants` | Proxy to relay `/api/tenants` for the member-picker. |
|
||||||
|
|
||||||
|
## API (relay)
|
||||||
|
|
||||||
|
Exact endpoints the local plugin forwards to. Authentication: the standard
|
||||||
|
`X-Relay-User` / `X-Relay-Key` client-API headers that the existing chat and
|
||||||
|
messages routes also use.
|
||||||
|
|
||||||
|
### Buckets
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/collab-buckets` | `{uid, name, color, members: [usernames]}`. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. |
|
||||||
|
| `GET` | `/api/collab-buckets` | Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as `members: [usernames]`. |
|
||||||
|
| `PATCH` | `/api/collab-buckets/{uid}` | Owner-only. Member-list replacement is total — pass the full desired list. |
|
||||||
|
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → `DELETE` of bucket + memberships (with `action: deleted`). Member → just that one membership row (with `action: left`); the bucket's `updated_at` is bumped so the owner sees the change on next poll. |
|
||||||
|
|
||||||
|
### Cards (Phase 3)
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/collab-cards` | `{bucket_uid, card_uid, payload}` — upsert. `payload` is a `HYPRCRD-RELAY-V1` envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via `masterDecrypt` of their stored `secret_encrypted`), re-encrypts with the inter-tenant key, stores. Server generates `updated_at` so all clients agree on the authoritative LWW timestamp. Membership-guarded. |
|
||||||
|
| `GET` | `/api/collab-cards?since=<ts>` | Pull cards updated since `<ts>` (ISO string from a previous response's `max_updated_at`) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with `deleted: true` and no payload. Hard-capped to 1000 cards per response. |
|
||||||
|
| `DELETE` | `/api/collab-cards/{bucket_uid}/{card_uid}` | Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. |
|
||||||
|
|
||||||
|
## Schema (local)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#38bdf8',
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
members_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TEXT -- NULL = never opened → unread
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: per-card sync state. Attached to the core `cards` table via
|
||||||
|
-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
|
||||||
|
-- relay; the push phase picks up cards where c.updated_at > last_push_at.
|
||||||
|
-- `last_remote_at` mirrors the updated_at we last applied from the relay.
|
||||||
|
CREATE TABLE collab_card_state (
|
||||||
|
card_id INTEGER PRIMARY KEY,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
last_push_at TEXT,
|
||||||
|
last_remote_at TEXT,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
|
||||||
|
-- cards table; drained in the push phase of the next poll.
|
||||||
|
CREATE TABLE collab_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`members_json` is a JSON array of usernames — flat and cheap to render.
|
||||||
|
Because the relay is the source of truth, we don't need to normalise
|
||||||
|
members into a separate local table.
|
||||||
|
|
||||||
|
The plugin also creates a BEFORE-DELETE trigger on `cards` that inserts a
|
||||||
|
tombstone row whenever a card in a collab bucket is deleted — unless the
|
||||||
|
suppress temp table `collab_suppress_outbox` has a row, which the plugin
|
||||||
|
sets during the poll's apply phase so remotely-triggered deletes don't
|
||||||
|
re-enqueue.
|
||||||
|
|
||||||
|
## Schema (relay)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#38bdf8',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE collab_bucket_members (
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (bucket_uid, tenant_id),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: the actual synced card content. `payload` is the re-encrypted
|
||||||
|
-- envelope stored at rest under the inter-tenant key. `deleted = 1`
|
||||||
|
-- marks a tombstone; bootstrap.php prunes them after 7 days.
|
||||||
|
CREATE TABLE collab_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
author_username TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (bucket_uid, card_uid)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner deletion cascades via `ON DELETE CASCADE` on the `owner_id` FK.
|
||||||
|
Member rows are pruned explicitly on leave (or cascade when a tenant is
|
||||||
|
deleted outright). `collab_cards` rows belonging to a deleted bucket are
|
||||||
|
orphaned but unreachable — the GET query joins `collab_bucket_members`
|
||||||
|
and returns nothing for them, so they're effectively gone. Future hardening
|
||||||
|
could add a cascade trigger.
|
||||||
|
|
||||||
|
## Card sync flow (Phase 3)
|
||||||
|
|
||||||
|
Every poll does three things in strict order inside one request:
|
||||||
|
|
||||||
|
1. **Drain tombstones.** The `collab_tombstones` outbox table is populated
|
||||||
|
by a BEFORE DELETE trigger on `cards` — whenever a user deletes a card
|
||||||
|
that sits in a collab bucket, the trigger inserts a row `{relay_uid,
|
||||||
|
bucket_uid}` into the outbox. The poll pushes each row to
|
||||||
|
`DELETE /api/collab-cards/{bucket}/{card}` and drops the outbox entry
|
||||||
|
on success. Failed pushes stay queued and retry next poll.
|
||||||
|
|
||||||
|
2. **Push dirty cards.** One query finds every card in a collab bucket
|
||||||
|
whose `updated_at` is greater than `collab_card_state.last_push_at`
|
||||||
|
(or never-pushed). Each is encrypted with the local `relay_key` and
|
||||||
|
POSTed to `/api/collab-cards`. On success, `last_push_at` is stamped
|
||||||
|
with the server's returned `updated_at` so the next poll skips it.
|
||||||
|
Cards without a `relay_uid` get one assigned (16 hex chars) and the
|
||||||
|
core `cards.relay_uid` column is updated so the card has a stable
|
||||||
|
cross-tenant identity.
|
||||||
|
|
||||||
|
3. **Pull deltas.** `GET /api/collab-cards?since=<stored>` fetches every
|
||||||
|
card updated or tombstoned since the stored marker (core `settings`
|
||||||
|
row `collab_cards_last_remote_at`). For each incoming card:
|
||||||
|
- If `deleted: true` → look up the local card by `relay_uid` and
|
||||||
|
`DELETE` it. The BEFORE DELETE trigger is suppressed via the
|
||||||
|
`collab_suppress_outbox` temp-table flag so it doesn't re-enqueue
|
||||||
|
the tombstone back into the outbox.
|
||||||
|
- Otherwise, decrypt the payload, find the local card by `relay_uid`,
|
||||||
|
and apply LWW (`remote.updated_at > local.updated_at` → overwrite).
|
||||||
|
New cards are INSERTed with `bucket_id` resolved via
|
||||||
|
`buckets.collab_uid`. In both cases `collab_card_state.last_push_at`
|
||||||
|
and `last_remote_at` are set to the remote's `updated_at` so the
|
||||||
|
next push phase doesn't re-send what was just received.
|
||||||
|
|
||||||
|
Conflict resolution is strict LWW on `updated_at`. If two users save the
|
||||||
|
same card within the same second, the second writer wins; the first
|
||||||
|
writer's changes are silently overwritten on their next pull. Phase 4
|
||||||
|
adds optional edit-locks to surface the conflict before it happens.
|
||||||
|
|
||||||
|
## Unread / pulse state
|
||||||
|
|
||||||
|
`has_unread` is computed by the plugin backend on every read of
|
||||||
|
`/api/collab-buckets`:
|
||||||
|
|
||||||
|
```
|
||||||
|
has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Brand-new bucket** → poll inserts the plugin row with `last_seen_at =
|
||||||
|
NULL` → unread.
|
||||||
|
- **Metadata / member change** → relay bumps `updated_at` → poll upserts →
|
||||||
|
`updated_at > last_seen_at` → unread.
|
||||||
|
- **Click tab** → `mark-seen` sets `last_seen_at = now` → unread clears on
|
||||||
|
the next render.
|
||||||
|
|
||||||
|
The plugin frontend watches `#bucket-bar` via `MutationObserver`. After
|
||||||
|
every core re-render, it walks the tabs, looks up each bucket in
|
||||||
|
`state.buckets`, and applies `.bucket-tab-collab` plus (if unread) the
|
||||||
|
`.pulsing` class and an injected `.collab-tab-dot` element. The pulse
|
||||||
|
animation is CSS-driven (`collab-bucket-pulse 1.2s ease-in-out 3`), and
|
||||||
|
the JS strips `.pulsing` after 3.7 s so subsequent re-renders don't
|
||||||
|
retrigger it mid-cycle.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
All chrome hooks into the standard design tokens:
|
||||||
|
|
||||||
|
- `--ui-radius-button` — tile and button rounding
|
||||||
|
- `--ui-radius-modal` — create/edit modal
|
||||||
|
- `--ui-radius-input` — form inputs
|
||||||
|
- `--ui-shadow-modal` — modal drop shadow
|
||||||
|
- `--ui-blur-overlay` — modal backdrop blur
|
||||||
|
- `--collab-tile-color` (per-tile) — bucket color, mixed into border, text,
|
||||||
|
hover background, and pulse ring
|
||||||
|
|
||||||
|
So a collab tile automatically inherits the currently selected theme and the
|
||||||
|
user's design-token choices without any plugin-side theming code.
|
||||||
|
|
||||||
|
## Why this shape
|
||||||
|
|
||||||
|
- **First-class bucket rows, not a separate UI.** Collab buckets show up
|
||||||
|
exactly where users already look for buckets. No extra header tiles, no
|
||||||
|
second "collab+" button — the existing `+ Bucket` workflow just gains a
|
||||||
|
"collaborative" checkbox. The mental model stays "this is a bucket", the
|
||||||
|
plugin only layers sync on top.
|
||||||
|
- **Polling, not push.** No long-lived connection, no WebSocket, no daemon.
|
||||||
|
30-second cadence is perfectly fine for the "someone added a card to our
|
||||||
|
shared bucket" latency envelope and reuses the existing relay HTTP path.
|
||||||
|
- **Relay as source of truth.** The local mirror is almost stateless — only
|
||||||
|
`last_seen_at` is user-specific and never leaves the device. Wipes and
|
||||||
|
re-syncs are trivial.
|
||||||
|
- **Owner-only writes.** Simpler than ACLs. Owners who want to hand off
|
||||||
|
control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
|
||||||
|
- **Soft coupling to core.** Core exposes two tiny hooks
|
||||||
|
(`window._collabBucketsCreateDialog`, `window.state`) plus a single
|
||||||
|
column on the buckets table. Everything else — dialog, decoration,
|
||||||
|
polling, mark-seen — lives in the plugin directory and goes away when
|
||||||
|
the plugin is disabled.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Like every other plugin, add `"collab-buckets"` to the `disabled` array in
|
||||||
|
`data/plugins.json`. The local table is left in place (not dropped) so
|
||||||
|
re-enabling doesn't lose unread state.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# collab-buckets plugin — roadmap
|
||||||
|
|
||||||
|
> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar
|
||||||
|
> integration, and full card sync with encrypted round-trip and LWW
|
||||||
|
> conflict resolution. Phase 4 is polish — edit locks, ownership
|
||||||
|
> transfer, member presence, etc. Phase 5 is hardening.
|
||||||
|
|
||||||
|
## Phase 3 — card sync (DONE ✓)
|
||||||
|
|
||||||
|
- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to
|
||||||
|
a collab `bucket_uid` + `relay_uid` plus `last_push_at` and
|
||||||
|
`last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards
|
||||||
|
via `c.updated_at > cs.last_push_at`.
|
||||||
|
- [x] **Save propagation without editor coupling** — the poll's push
|
||||||
|
phase is query-based (no trigger, no hook). Whenever the user
|
||||||
|
saves a card and the core bumps `cards.updated_at`, the next
|
||||||
|
poll within 30s finds it dirty and pushes.
|
||||||
|
- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards`
|
||||||
|
decrypts-reencrypts via inter-tenant key; `GET ?since=<ts>`
|
||||||
|
re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a
|
||||||
|
tombstone with a 7-day TTL (cleaned up by bootstrap.php).
|
||||||
|
- [x] **Poll extension** — `collabSyncCards()` runs three phases
|
||||||
|
(tombstones → push → pull) with a temp-table suppress flag so the
|
||||||
|
delete trigger doesn't re-enqueue during apply.
|
||||||
|
- [x] **Card visibility** — collab buckets already take part in the core
|
||||||
|
bucket-bar's click-to-filter flow, so cards synced into them
|
||||||
|
automatically show up on the board.
|
||||||
|
- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to
|
||||||
|
`collab_tombstones`. Drained in the next poll as a DELETE.
|
||||||
|
Suppressed during the apply phase so remote deletes don't loop
|
||||||
|
back as local tombstones.
|
||||||
|
- [x] **Conflict losers as "(conflict from X)" duplicates** — when the
|
||||||
|
apply phase is about to LWW-clobber a local card that had edits
|
||||||
|
since our last push (`local.updated_at > collab_card_state.last_push_at`),
|
||||||
|
we now INSERT a copy of the local version into a local-only
|
||||||
|
"Conflicts" bucket with a `(conflict from <author>) ...` title
|
||||||
|
prefix before the overwrite. The poll response carries a
|
||||||
|
`conflicts` count; the frontend pops a toast pointing the user at
|
||||||
|
the Conflicts bucket when it's non-zero.
|
||||||
|
- [x] **Moving a card between collab buckets (or out to a local one)**
|
||||||
|
— the push phase now has a pre-pass that finds cards where
|
||||||
|
`collab_card_state.bucket_uid` no longer matches the card's
|
||||||
|
current bucket collab_uid. For each such card it sends a
|
||||||
|
tombstone to the old bucket. If the card is still in a (different)
|
||||||
|
collab bucket, state is rewritten with the new `bucket_uid` and
|
||||||
|
`last_push_at = NULL` so the main push loop re-uploads under the
|
||||||
|
new bucket. If the card left collab entirely, the state row is
|
||||||
|
removed.
|
||||||
|
|
||||||
|
## Phase 4 — polish
|
||||||
|
|
||||||
|
- [x] **Per-card edit lock** — when the editor opens on a collab card the
|
||||||
|
plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}`
|
||||||
|
every 30 s with a 60 s TTL on the relay. The lock list is folded
|
||||||
|
into the regular `/api/collab-buckets/poll` response so no extra
|
||||||
|
round-trips are needed. Foreign locks show a 🔒 badge on the
|
||||||
|
affected board card + a subtle inset outline. Releases on editor
|
||||||
|
close and best-effort on `beforeunload` via `fetch(..., keepalive)`.
|
||||||
|
Advisory only — users can still force-edit; LWW + conflict stashing
|
||||||
|
remain the final arbiter. Core `editor.js` dispatches
|
||||||
|
`hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so
|
||||||
|
the plugin hooks in without monkey-patching.
|
||||||
|
- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}`
|
||||||
|
accepts `{owner: <username>}`. The relay validates that the new
|
||||||
|
owner is currently a member, swaps `owner_id` + `owner_username`,
|
||||||
|
and forces both the caller and the new owner to stay in the member
|
||||||
|
list on the same request. Surfaced in the UI via a new "Members &
|
||||||
|
owner…" entry in the bucket context menu that opens a plugin
|
||||||
|
dialog with a member picker + owner dropdown (only shown when the
|
||||||
|
caller is the current owner; other members see a read-only view).
|
||||||
|
- [ ] **Position propagation (optional)** — reorder is purely local right
|
||||||
|
now. If multiple members expect the same order, we'd need to sync
|
||||||
|
`position` via the relay, but it's probably fine to leave this as a
|
||||||
|
per-user preference.
|
||||||
|
- [ ] **Member presence indicator** — show a small dot next to usernames in
|
||||||
|
the members panel when they were seen by the relay within the last
|
||||||
|
minute (reuse chat's heartbeat or add a collab-specific ping).
|
||||||
|
- [ ] **"Recently active members"** — show avatars (first letter) of
|
||||||
|
members who touched a card in the last hour, on the collab bucket tab.
|
||||||
|
- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each
|
||||||
|
collab bucket. Requires the relay to store per-member `last_seen_at`
|
||||||
|
— bigger schema change.
|
||||||
|
|
||||||
|
## Phase 5 — hardening & UX
|
||||||
|
|
||||||
|
- [x] **Migration of existing local buckets** — the bucket context menu
|
||||||
|
on a non-collab bucket shows a "Make collaborative…" entry when
|
||||||
|
the plugin is loaded. It opens a plugin dialog where the owner
|
||||||
|
picks members, then `POST /api/collab-buckets/upgrade
|
||||||
|
{bucket_id, members}` generates a new `uid`, pushes it to the
|
||||||
|
relay, and writes `collab_uid` onto the same local `buckets` row
|
||||||
|
— no card shuffling, no id rewrite. The first poll after the
|
||||||
|
upgrade sees every card in the bucket as dirty (no
|
||||||
|
`collab_card_state`) and pushes them.
|
||||||
|
- [x] **Undo for destructive actions** — local bucket delete/leave now
|
||||||
|
hooks into the existing `pushUndo` system: the undo entry
|
||||||
|
recreates the bucket via `POST /api/buckets`, restores the
|
||||||
|
original color+position with PATCH, and re-attaches every card
|
||||||
|
that pointed at the old row. Collab bucket delete/leave is
|
||||||
|
intentionally *not* wired — restoring would need to recreate on
|
||||||
|
the relay under a new uid and re-attach cards there too, which
|
||||||
|
is messy and less valuable since collab deletes are usually
|
||||||
|
deliberate.
|
||||||
|
- [x] **Resilient create via outbox** — a new
|
||||||
|
`collab_bucket_outbox(id, bucket_id, members_json, queued_at)`
|
||||||
|
table. The create/upgrade flow always creates the local `buckets`
|
||||||
|
row first and then tries `collabAttemptUpgrade()`. On relay
|
||||||
|
failure the intent is queued in the outbox and the next poll
|
||||||
|
retries. The response includes `queued: true` so the frontend can
|
||||||
|
show a "saved locally, will sync later" info toast; a follow-up
|
||||||
|
poll that successfully drains the outbox fires a second toast.
|
||||||
|
Stale outbox entries for deleted buckets are dropped automatically.
|
||||||
|
- [x] **Rate limiting on collab endpoints** — every collab route now
|
||||||
|
funnels through the existing relay `isRateLimited()` helper with
|
||||||
|
generous caps (120/min for GET, 240/min for POST). Over-limit
|
||||||
|
requests get a clean `429 rate limited` response.
|
||||||
|
- [ ] **Inline member picker in the bucket context menu** — currently
|
||||||
|
needs the manage-modal for any change. A quick `+user` / `-user`
|
||||||
|
flyout would help for tiny edits.
|
||||||
|
- [ ] **Owner-side member approval flow** — owners can still add any
|
||||||
|
relay user unilaterally. A pending-invite state with accept/reject
|
||||||
|
on the invitee side would be nicer for consent but requires a new
|
||||||
|
relay table + plumbing.
|
||||||
|
|
||||||
|
## Known limitations (current)
|
||||||
|
|
||||||
|
- **LWW conflicts still clobber on the remote side.** When two users save
|
||||||
|
the same card within a few seconds, the later writer wins. The earlier
|
||||||
|
writer gets their local version stashed into a `Conflicts` bucket and
|
||||||
|
a toast notification — nothing is lost — but their version is no
|
||||||
|
longer the canonical one anyone else sees. Phase 4 edit-locks prevent
|
||||||
|
the race in the first place.
|
||||||
|
- **Up to ~30 s sync latency.** Changes propagate on the next poll, not
|
||||||
|
instantly. Acceptable for MVP; a "push now" hook after save would cut
|
||||||
|
latency to ~1 s.
|
||||||
|
- **Full resync on every poll.** Cheap for small member counts but grows
|
||||||
|
O(N_buckets × N_members) on the relay side. A `since=updated_at` filter
|
||||||
|
would help — adding it is safe because the poll also has to handle full
|
||||||
|
removals (kicked from bucket), and we can fold the deletion list into the
|
||||||
|
response.
|
||||||
|
- **Bucket metadata is plaintext on the relay.** Name + color + member list
|
||||||
|
are visible to a relay admin with DB access. Card content (Phase 3) will
|
||||||
|
be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket
|
||||||
|
metadata could follow the same pattern but adds complexity for minimal
|
||||||
|
gain and makes member picking harder.
|
||||||
|
- **No offline support.** Creating a bucket while the relay is unreachable
|
||||||
|
fails hard. Phase 5 outbox pattern would fix this.
|
||||||
|
- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))`
|
||||||
|
= 64 bits of entropy. Collisions between independent clients are
|
||||||
|
astronomically unlikely, but the relay rejects them with 409 if they ever
|
||||||
|
happen. No retry on the client side yet.
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collab Buckets Plugin: backend.
|
||||||
|
*
|
||||||
|
* Local mirror of the relay's collab_buckets state. Provides a thin proxy
|
||||||
|
* over the relay's /api/collab-buckets routes plus a poll endpoint that
|
||||||
|
* does a full resync (so deletions / removed memberships propagate).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/collab-buckets list local buckets
|
||||||
|
* POST /api/collab-buckets {name, color, members[]} -> create on relay + mirror local
|
||||||
|
* PATCH /api/collab-buckets/{uid} {name?, color?, members?} -> push to relay
|
||||||
|
* DELETE /api/collab-buckets/{uid} owner: delete; member: leave
|
||||||
|
* POST /api/collab-buckets/poll full re-sync from relay
|
||||||
|
* POST /api/collab-buckets/mark-seen {uid} -> reset dot/pulse state
|
||||||
|
* GET /api/collab-buckets/relay-tenants proxy to relay /api/tenants for the member-picker
|
||||||
|
*
|
||||||
|
* Phase 1+2: bucket metadata + membership only. Card sync lands in Phase 3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#38bdf8',
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
members_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TEXT
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_buckets_uid ON collab_buckets(uid)");
|
||||||
|
|
||||||
|
// ── Phase 3 schema: card sync tracking ───────────────────────
|
||||||
|
// Per-card state: which relay uid and bucket uid the card maps to, plus
|
||||||
|
// the last time we pushed it and the last remote updated_at we applied.
|
||||||
|
// `last_push_at` is compared against `cards.updated_at` to find dirty
|
||||||
|
// cards that need a push. On apply, `last_push_at` is bumped to the
|
||||||
|
// remote's updated_at so we don't re-push what we just received.
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_card_state (
|
||||||
|
card_id INTEGER PRIMARY KEY,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
last_push_at TEXT,
|
||||||
|
last_remote_at TEXT,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
|
||||||
|
|
||||||
|
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
|
||||||
|
// table. The poll's push phase drains it and sends each entry as a
|
||||||
|
// DELETE to the relay.
|
||||||
|
//
|
||||||
|
// The trigger fires unconditionally on any collab-card delete, including
|
||||||
|
// deletions caused by *applying a remote tombstone*. That would loop the
|
||||||
|
// tombstone back to the relay pointlessly. To break the loop without
|
||||||
|
// needing a temp-table suppress flag (SQLite forbids triggers on
|
||||||
|
// permanent tables from referencing `temp.*` objects), the apply phase
|
||||||
|
// snapshots MAX(id) on `collab_tombstones` before it starts and deletes
|
||||||
|
// every row with id > that marker after it finishes — i.e. everything
|
||||||
|
// that the apply phase itself caused.
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
// Phase 5: create-outbox for buckets. When the user creates (or
|
||||||
|
// upgrades) a collab bucket but the relay is unreachable, the local
|
||||||
|
// row is created anyway and an entry is queued here. The poll drains
|
||||||
|
// it on every tick by retrying the upgrade.
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_bucket_outbox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_id INTEGER NOT NULL,
|
||||||
|
members_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("
|
||||||
|
CREATE TRIGGER IF NOT EXISTS collab_card_delete_tombstone
|
||||||
|
BEFORE DELETE ON cards
|
||||||
|
WHEN OLD.bucket_id IS NOT NULL
|
||||||
|
AND OLD.relay_uid IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO collab_tombstones (relay_uid, bucket_uid)
|
||||||
|
SELECT OLD.relay_uid, b.collab_uid
|
||||||
|
FROM buckets b
|
||||||
|
WHERE b.id = OLD.bucket_id AND b.collab_uid IS NOT NULL;
|
||||||
|
END
|
||||||
|
");
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** Encrypt a JSON payload with the local relay_key using the same envelope
|
||||||
|
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
|
||||||
|
function collabEncryptPayload(array $fields): ?string {
|
||||||
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
|
if ($key === '') return null;
|
||||||
|
$encKey = substr(hash('sha256', $key), 0, 32);
|
||||||
|
$plain = json_encode($fields);
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($cipher === false) return null;
|
||||||
|
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
||||||
|
function collabDecryptPayload(string $envelope): ?array {
|
||||||
|
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
|
||||||
|
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||||
|
$raw = base64_decode($b64);
|
||||||
|
if (!$raw || strlen($raw) < 17) return null;
|
||||||
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
|
if ($key === '') return null;
|
||||||
|
$encKey = substr(hash('sha256', $key), 0, 32);
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($plain === false) return null;
|
||||||
|
$data = json_decode($plain, true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collabJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collabRelaySettings(): array {
|
||||||
|
$db = Database::connect();
|
||||||
|
return [
|
||||||
|
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
|
||||||
|
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
|
||||||
|
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collabRelayCall(string $path, string $method = 'GET', $body = null): array {
|
||||||
|
$r = collabRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||||
|
return ['ok' => false, 'error' => 'relay not configured (set URL, username, and secret in settings)'];
|
||||||
|
}
|
||||||
|
$url = rtrim($r['url'], '/') . $path;
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
];
|
||||||
|
if ($body !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($body);
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
$decoded = json_decode($response ?: '', true);
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'status' => $status,
|
||||||
|
'error' => is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : "HTTP $status",
|
||||||
|
'url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$decoded = json_decode($response ?: '', true);
|
||||||
|
return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'invalid relay response'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a local bucket row for the frontend. */
|
||||||
|
function collabShapeLocalBucket(array $r): array {
|
||||||
|
$members = json_decode($r['members_json'] ?? '[]', true) ?: [];
|
||||||
|
return [
|
||||||
|
'uid' => $r['uid'],
|
||||||
|
'name' => $r['name'],
|
||||||
|
'color' => $r['color'],
|
||||||
|
'owner_username' => $r['owner_username'],
|
||||||
|
'members' => $members,
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
'updated_at' => $r['updated_at'],
|
||||||
|
'last_seen_at' => $r['last_seen_at'],
|
||||||
|
// A bucket has unread state if it was never opened OR if remote
|
||||||
|
// updated it after the last time we opened it.
|
||||||
|
'has_unread' => $r['last_seen_at'] === null || $r['updated_at'] > $r['last_seen_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the corresponding row in the main `buckets` table so the collab
|
||||||
|
* bucket shows up in the standard bucket bar. Returns the main bucket id. */
|
||||||
|
function collabUpsertMainBucket(string $uid, string $name, string $color): int {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
|
||||||
|
$stmt->execute(['u' => $uid]);
|
||||||
|
$existing = $stmt->fetchColumn();
|
||||||
|
if ($existing) {
|
||||||
|
$db->prepare('UPDATE buckets SET name = :n, color = :c WHERE id = :id')
|
||||||
|
->execute(['n' => $name, 'c' => $color, 'id' => $existing]);
|
||||||
|
return (int)$existing;
|
||||||
|
}
|
||||||
|
// Put new collab buckets at the end of the bar
|
||||||
|
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
|
||||||
|
$db->prepare('INSERT INTO buckets (name, color, collab_uid, position) VALUES (:n, :c, :u, :p)')
|
||||||
|
->execute(['n' => $name, 'c' => $color, 'u' => $uid, 'p' => $maxPos + 1]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the main `buckets` row for a collab bucket (if any) and unlink its
|
||||||
|
* cards. Uses the same ON DELETE SET NULL semantics as the regular delete path. */
|
||||||
|
function collabDeleteMainBucket(string $uid): void {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
|
||||||
|
$stmt->execute(['u' => $uid]);
|
||||||
|
$bid = $stmt->fetchColumn();
|
||||||
|
if (!$bid) return;
|
||||||
|
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $bid]);
|
||||||
|
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $bid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared upgrade path: given a local buckets row, generate a fresh uid,
|
||||||
|
* push the create to the relay, then (on success) rewrite the local
|
||||||
|
* collab_uid and install the plugin mirror row. Used by both the
|
||||||
|
* "upgrade existing bucket" route AND the new outbox drain that retries
|
||||||
|
* failed creates.
|
||||||
|
*
|
||||||
|
* Returns an array with:
|
||||||
|
* ok=true + uid on success
|
||||||
|
* ok=false + error on relay failure — the caller decides
|
||||||
|
* whether to queue a retry in the outbox
|
||||||
|
* ok=true + already=true if the bucket already has a collab_uid
|
||||||
|
*/
|
||||||
|
function collabAttemptUpgrade(int $bucketId, array $members): array {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT id, name, color, collab_uid FROM buckets WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $bucketId]);
|
||||||
|
$bucket = $stmt->fetch();
|
||||||
|
if (!$bucket) return ['ok' => false, 'error' => 'bucket not found'];
|
||||||
|
if (!empty($bucket['collab_uid'])) {
|
||||||
|
return ['ok' => true, 'already' => true, 'uid' => $bucket['collab_uid']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uid = bin2hex(random_bytes(8));
|
||||||
|
$res = collabRelayCall('/api/collab-buckets', 'POST', [
|
||||||
|
'uid' => $uid,
|
||||||
|
'name' => $bucket['name'],
|
||||||
|
'color' => $bucket['color'],
|
||||||
|
'members' => $members,
|
||||||
|
]);
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
return ['ok' => false, 'error' => $res['error'] ?? 'relay create failed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare('UPDATE buckets SET collab_uid = :u WHERE id = :id')
|
||||||
|
->execute(['u' => $uid, 'id' => $bucketId]);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$self = collabRelaySettings()['user'];
|
||||||
|
$resolvedMembers = $res['members'] ?? $members;
|
||||||
|
$db->prepare('
|
||||||
|
INSERT INTO collab_buckets
|
||||||
|
(uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES
|
||||||
|
(:uid, :n, :c, :ou, :m, :ca, :ua, :ls)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
name = excluded.name, color = excluded.color,
|
||||||
|
owner_username = excluded.owner_username,
|
||||||
|
members_json = excluded.members_json,
|
||||||
|
updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
|
||||||
|
')->execute([
|
||||||
|
'uid' => $uid, 'n' => $bucket['name'], 'c' => $bucket['color'],
|
||||||
|
'ou' => $self,
|
||||||
|
'm' => json_encode($resolvedMembers),
|
||||||
|
'ca' => $now, 'ua' => $now,
|
||||||
|
'ls' => $now,
|
||||||
|
]);
|
||||||
|
return ['ok' => true, 'uid' => $uid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find or create a local-only "Conflicts" bucket where LWW losers get
|
||||||
|
* stashed. Never gets a collab_uid so it doesn't sync anywhere. */
|
||||||
|
function collabFindOrCreateConflictsBucket(): int {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare("SELECT id FROM buckets WHERE name = 'Conflicts' AND collab_uid IS NULL");
|
||||||
|
$stmt->execute();
|
||||||
|
$id = $stmt->fetchColumn();
|
||||||
|
if ($id) return (int)$id;
|
||||||
|
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
|
||||||
|
$db->prepare("INSERT INTO buckets (name, color, position) VALUES ('Conflicts', '#fb7185', :p)")
|
||||||
|
->execute(['p' => $maxPos + 1]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duplicate a card into the Conflicts bucket with a title marker, so the
|
||||||
|
* local user can see what they lost before the remote overwrite applied.
|
||||||
|
* Called from the apply phase when LWW is about to clobber unsynced local
|
||||||
|
* edits (local.updated_at > last_push_at && remote.updated_at > local). */
|
||||||
|
function collabStashConflict(PDO $db, array $local, string $remoteAuthor): void {
|
||||||
|
$bid = collabFindOrCreateConflictsBucket();
|
||||||
|
$origTitle = (string)($local['title'] ?? '');
|
||||||
|
$newTitle = '(conflict' . ($remoteAuthor !== '' ? ' from ' . $remoteAuthor : '') . ') ' . $origTitle;
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, created_at, updated_at)
|
||||||
|
VALUES (:t, :b, :c, :tg, :p, :d, :bid, datetime('now'), datetime('now'))
|
||||||
|
")->execute([
|
||||||
|
't' => substr($newTitle, 0, 400),
|
||||||
|
'b' => $local['body'] ?? '',
|
||||||
|
'c' => $local['color'] ?? '#fb7185',
|
||||||
|
'tg' => $local['tags'] ?? '',
|
||||||
|
'p' => (int)($local['position'] ?? 0),
|
||||||
|
'd' => $local['due_date'] ?? null,
|
||||||
|
'bid' => $bid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the card payload we send to the relay. Fields that don't matter
|
||||||
|
* cross-user (parent_id for groups, caldav_*, the autoinc id) are omitted. */
|
||||||
|
function collabBuildCardPayload(array $card): array {
|
||||||
|
return [
|
||||||
|
'title' => $card['title'] ?? '',
|
||||||
|
'body' => $card['body'] ?? '',
|
||||||
|
'color' => $card['color'] ?? '#38bdf8',
|
||||||
|
'tags' => $card['tags'] ?? '',
|
||||||
|
'position' => (int)($card['position'] ?? 0),
|
||||||
|
'due_date' => $card['due_date'] ?? null,
|
||||||
|
'updated_at' => $card['updated_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3 sync: push local changes + pull remote deltas for all collab
|
||||||
|
* buckets. Returns counters for the response body.
|
||||||
|
*
|
||||||
|
* Order matters:
|
||||||
|
* 1. Drain tombstones — pushes delete-your-card notifications before we
|
||||||
|
* pull, otherwise we might immediately re-apply a remote version of
|
||||||
|
* the just-deleted card.
|
||||||
|
* 2. Push dirty cards — `cards.updated_at > collab_card_state.last_push_at`
|
||||||
|
* means the user edited since we last pushed. Encrypt, POST, stamp
|
||||||
|
* last_push_at so the next poll skips.
|
||||||
|
* 3. Pull deltas — since the highest `last_remote_at` we have, apply LWW
|
||||||
|
* to local rows. The suppress temp-table flag keeps the BEFORE DELETE
|
||||||
|
* trigger from re-queueing remotely-triggered deletes.
|
||||||
|
*/
|
||||||
|
function collabSyncCards(PDO $db): array {
|
||||||
|
$relay = collabRelaySettings();
|
||||||
|
$stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0, 'blocked' => 0, 'blocked_by' => []];
|
||||||
|
if (!$relay['url'] || !$relay['user'] || !$relay['key']) return $stats;
|
||||||
|
|
||||||
|
// ── 1. Drain tombstones ────────────────────────────────
|
||||||
|
$tombRows = $db->query('SELECT id, relay_uid, bucket_uid FROM collab_tombstones ORDER BY id ASC')->fetchAll();
|
||||||
|
foreach ($tombRows as $t) {
|
||||||
|
$res = collabRelayCall(
|
||||||
|
'/api/collab-cards/' . urlencode($t['bucket_uid']) . '/' . urlencode($t['relay_uid']),
|
||||||
|
'DELETE'
|
||||||
|
);
|
||||||
|
if (($res['ok'] ?? false)) {
|
||||||
|
$db->prepare('DELETE FROM collab_tombstones WHERE id = :id')->execute(['id' => $t['id']]);
|
||||||
|
$stats['tombstones_sent']++;
|
||||||
|
} else if (($res['status'] ?? 0) === 423) {
|
||||||
|
// Lock holder is editing — don't delete under their feet.
|
||||||
|
// Tombstone stays queued; next poll retries after release.
|
||||||
|
$stats['blocked']++;
|
||||||
|
$by = $res['locked_by'] ?? '';
|
||||||
|
if ($by !== '' && !in_array($by, $stats['blocked_by'], true)) $stats['blocked_by'][] = $by;
|
||||||
|
}
|
||||||
|
// If a tombstone push fails for any other reason it stays queued
|
||||||
|
// and gets retried next poll.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1b. Detect moved cards ─────────────────────────────
|
||||||
|
// A card previously tracked in collab_card_state under bucket_uid X can
|
||||||
|
// now be in (a) no bucket, (b) a local bucket without collab_uid, or
|
||||||
|
// (c) a different collab bucket. In all three cases we need to push a
|
||||||
|
// tombstone to the original bucket so other members stop seeing the
|
||||||
|
// stale copy. If the card is still a collab card in a new bucket, the
|
||||||
|
// normal push phase below picks it up and pushes under the new uid.
|
||||||
|
$movedRows = $db->query("
|
||||||
|
SELECT cs.card_id, cs.relay_uid, cs.bucket_uid AS old_bucket_uid,
|
||||||
|
c.id IS NULL AS card_missing,
|
||||||
|
b.collab_uid AS new_bucket_uid
|
||||||
|
FROM collab_card_state cs
|
||||||
|
LEFT JOIN cards c ON c.id = cs.card_id
|
||||||
|
LEFT JOIN buckets b ON b.id = c.bucket_id
|
||||||
|
WHERE c.id IS NULL
|
||||||
|
OR b.collab_uid IS NULL
|
||||||
|
OR b.collab_uid != cs.bucket_uid
|
||||||
|
")->fetchAll();
|
||||||
|
foreach ($movedRows as $mv) {
|
||||||
|
// Tombstone on the OLD bucket — members of that bucket should stop
|
||||||
|
// seeing this card regardless of where it went.
|
||||||
|
$res = collabRelayCall(
|
||||||
|
'/api/collab-cards/' . urlencode($mv['old_bucket_uid']) . '/' . urlencode($mv['relay_uid']),
|
||||||
|
'DELETE'
|
||||||
|
);
|
||||||
|
if (!($res['ok'] ?? false)) continue; // retry next poll
|
||||||
|
$stats['moved']++;
|
||||||
|
|
||||||
|
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
|
||||||
|
// Card is gone or moved out of collab entirely — forget about it
|
||||||
|
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
|
||||||
|
->execute(['id' => $mv['card_id']]);
|
||||||
|
} else {
|
||||||
|
// Card moved to a different collab bucket. Rewrite state so the
|
||||||
|
// push phase below picks it up under the new bucket_uid and also
|
||||||
|
// resets last_push_at so the push actually fires.
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE collab_card_state
|
||||||
|
SET bucket_uid = :b, last_push_at = NULL
|
||||||
|
WHERE card_id = :id
|
||||||
|
")->execute(['b' => $mv['new_bucket_uid'], 'id' => $mv['card_id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Push dirty cards ────────────────────────────────
|
||||||
|
// Find every card in a collab bucket whose updated_at is newer than the
|
||||||
|
// last time we pushed it (or was never pushed).
|
||||||
|
$dirty = $db->query("
|
||||||
|
SELECT c.id, c.title, c.body, c.color, c.tags, c.position, c.due_date,
|
||||||
|
c.updated_at, c.relay_uid, b.collab_uid AS bucket_uid,
|
||||||
|
cs.last_push_at
|
||||||
|
FROM cards c
|
||||||
|
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
||||||
|
LEFT JOIN collab_card_state cs ON cs.card_id = c.id
|
||||||
|
WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
||||||
|
$stateUpsert = $db->prepare("
|
||||||
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
||||||
|
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
|
||||||
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
|
relay_uid = excluded.relay_uid,
|
||||||
|
bucket_uid = excluded.bucket_uid,
|
||||||
|
last_push_at = excluded.last_push_at
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($dirty as $card) {
|
||||||
|
// Ensure the card has a relay_uid (cross-tenant identity)
|
||||||
|
$relayUid = $card['relay_uid'];
|
||||||
|
if (!$relayUid) {
|
||||||
|
$relayUid = bin2hex(random_bytes(8));
|
||||||
|
$assignUid->execute(['u' => $relayUid, 'id' => $card['id']]);
|
||||||
|
}
|
||||||
|
$payload = collabEncryptPayload(collabBuildCardPayload($card));
|
||||||
|
if ($payload === null) continue; // no relay_key or openssl failure
|
||||||
|
|
||||||
|
$res = collabRelayCall('/api/collab-cards', 'POST', [
|
||||||
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
|
'card_uid' => $relayUid,
|
||||||
|
'payload' => $payload,
|
||||||
|
]);
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
// 423 = relay refused because someone else holds an exclusive
|
||||||
|
// lock. Don't bump last_push_at — keep the card dirty so we
|
||||||
|
// retry on the next poll. Surface the holder name so the
|
||||||
|
// frontend can toast.
|
||||||
|
if (($res['status'] ?? 0) === 423) {
|
||||||
|
$stats['blocked']++;
|
||||||
|
$by = $res['locked_by'] ?? '';
|
||||||
|
if ($by !== '' && !in_array($by, $stats['blocked_by'], true)) $stats['blocked_by'][] = $by;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stateUpsert->execute([
|
||||||
|
'cid' => $card['id'],
|
||||||
|
'cid2' => $card['id'],
|
||||||
|
'ru' => $relayUid,
|
||||||
|
'bu' => $card['bucket_uid'],
|
||||||
|
'push' => $res['updated_at'] ?? $card['updated_at'],
|
||||||
|
]);
|
||||||
|
$stats['pushed']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Pull deltas ─────────────────────────────────────
|
||||||
|
$since = $db->query("SELECT value FROM settings WHERE key = 'collab_cards_last_remote_at'")->fetchColumn();
|
||||||
|
if (!$since) $since = '';
|
||||||
|
$qs = $since !== '' ? ('?since=' . urlencode($since)) : '';
|
||||||
|
$res = collabRelayCall('/api/collab-cards' . $qs);
|
||||||
|
if (!($res['ok'] ?? false)) return $stats;
|
||||||
|
|
||||||
|
$incoming = $res['cards'] ?? [];
|
||||||
|
$maxTs = $res['max_updated_at'] ?? $since;
|
||||||
|
if (!$incoming) {
|
||||||
|
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The delete trigger fires unconditionally on collab-card deletes,
|
||||||
|
// including the ones the apply phase itself causes. To avoid looping
|
||||||
|
// the delete back to the relay we snapshot the highest tombstone id
|
||||||
|
// before apply and purge everything > that marker afterwards.
|
||||||
|
$tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn();
|
||||||
|
// Buckets that received changes from a foreign author. Used at the end
|
||||||
|
// of the apply phase to bump collab_buckets.updated_at, which the
|
||||||
|
// frontend's has_unread calculation reads — that's how a different
|
||||||
|
// bucket-bar tab gets the unread dot + 3-pulse when a peer pushes a
|
||||||
|
// card while the user is looking at something else.
|
||||||
|
$selfUser = collabRelaySettings()['user'];
|
||||||
|
$unreadBuckets = [];
|
||||||
|
try {
|
||||||
|
foreach ($incoming as $c) {
|
||||||
|
// Resolve local bucket id
|
||||||
|
$bid = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
|
||||||
|
$bid->execute(['u' => $c['bucket_uid']]);
|
||||||
|
$localBucketId = $bid->fetchColumn();
|
||||||
|
if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen
|
||||||
|
|
||||||
|
$foreignAuthor = ($c['author_username'] ?? '') !== $selfUser;
|
||||||
|
|
||||||
|
// Find local card by relay_uid — grab all fields so we can
|
||||||
|
// stash a conflict copy if needed before overwriting
|
||||||
|
$find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u');
|
||||||
|
$find->execute(['u' => $c['card_uid']]);
|
||||||
|
$local = $find->fetch();
|
||||||
|
|
||||||
|
if (!empty($c['deleted'])) {
|
||||||
|
if ($local) {
|
||||||
|
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]);
|
||||||
|
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]);
|
||||||
|
$stats['deleted_local']++;
|
||||||
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt payload
|
||||||
|
$fields = collabDecryptPayload($c['payload'] ?? '');
|
||||||
|
if ($fields === null) continue;
|
||||||
|
|
||||||
|
if ($local) {
|
||||||
|
// LWW: only accept remote if it's strictly newer than local
|
||||||
|
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
|
||||||
|
|
||||||
|
// Conflict detection: if the local card has local edits that
|
||||||
|
// were never pushed (local.updated_at > last_push_at), we're
|
||||||
|
// about to clobber them. Stash a copy into the local-only
|
||||||
|
// Conflicts bucket before overwriting.
|
||||||
|
$cs = $db->prepare('SELECT last_push_at FROM collab_card_state WHERE card_id = :id');
|
||||||
|
$cs->execute(['id' => $local['id']]);
|
||||||
|
$lastPushAt = $cs->fetchColumn();
|
||||||
|
if ($lastPushAt === false) $lastPushAt = null;
|
||||||
|
$hasUnpushed = $lastPushAt === null
|
||||||
|
? false // first apply of a remote card — no "local edits" yet
|
||||||
|
: ($local['updated_at'] ?? '') > $lastPushAt;
|
||||||
|
if ($hasUnpushed) {
|
||||||
|
collabStashConflict($db, $local, (string)($c['author_username'] ?? ''));
|
||||||
|
$stats['conflicts']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE cards SET
|
||||||
|
title = :t, body = :b, color = :c, tags = :tg,
|
||||||
|
position = :p, due_date = :d,
|
||||||
|
bucket_id = :bid,
|
||||||
|
updated_at = :ua
|
||||||
|
WHERE id = :id
|
||||||
|
")->execute([
|
||||||
|
't' => $fields['title'] ?? '',
|
||||||
|
'b' => $fields['body'] ?? '',
|
||||||
|
'c' => $fields['color'] ?? '#38bdf8',
|
||||||
|
'tg' => $fields['tags'] ?? '',
|
||||||
|
'p' => (int)($fields['position'] ?? 0),
|
||||||
|
'd' => $fields['due_date'] ?? null,
|
||||||
|
'bid' => $localBucketId,
|
||||||
|
'ua' => $c['updated_at'],
|
||||||
|
'id' => $local['id'],
|
||||||
|
]);
|
||||||
|
$localId = (int)$local['id'];
|
||||||
|
} else {
|
||||||
|
// New card
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, relay_uid, created_at, updated_at)
|
||||||
|
VALUES (:t, :b, :c, :tg, :p, :d, :bid, :u, :ca, :ua)
|
||||||
|
")->execute([
|
||||||
|
't' => $fields['title'] ?? '',
|
||||||
|
'b' => $fields['body'] ?? '',
|
||||||
|
'c' => $fields['color'] ?? '#38bdf8',
|
||||||
|
'tg' => $fields['tags'] ?? '',
|
||||||
|
'p' => (int)($fields['position'] ?? 0),
|
||||||
|
'd' => $fields['due_date'] ?? null,
|
||||||
|
'bid' => $localBucketId,
|
||||||
|
'u' => $c['card_uid'],
|
||||||
|
'ca' => $c['updated_at'],
|
||||||
|
'ua' => $c['updated_at'],
|
||||||
|
]);
|
||||||
|
$localId = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the remote state so we don't push it back on the next poll
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
||||||
|
VALUES (:cid, :ru, :bu, :p, :r)
|
||||||
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
|
relay_uid = excluded.relay_uid,
|
||||||
|
bucket_uid = excluded.bucket_uid,
|
||||||
|
last_push_at = excluded.last_push_at,
|
||||||
|
last_remote_at = excluded.last_remote_at
|
||||||
|
")->execute([
|
||||||
|
'cid' => $localId,
|
||||||
|
'ru' => $c['card_uid'],
|
||||||
|
'bu' => $c['bucket_uid'],
|
||||||
|
'p' => $c['updated_at'],
|
||||||
|
'r' => $c['updated_at'],
|
||||||
|
]);
|
||||||
|
$stats['pulled']++;
|
||||||
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Bump `collab_buckets.updated_at` for every bucket that received
|
||||||
|
// changes from another user. The frontend's has_unread is
|
||||||
|
// `updated_at > last_seen_at`, so this is what triggers the unread
|
||||||
|
// dot + 3-pulse on the bucket-bar tab when the user is looking at
|
||||||
|
// something else. Skipped for the user's own pushes since the
|
||||||
|
// relay echoes those back too.
|
||||||
|
if ($unreadBuckets) {
|
||||||
|
$bumpStmt = $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u");
|
||||||
|
foreach (array_keys($unreadBuckets) as $u) {
|
||||||
|
$bumpStmt->execute(['u' => $u]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Purge any tombstones the apply phase itself created. Tombstones
|
||||||
|
// queued before apply (by actual user deletes) survive and get
|
||||||
|
// drained on the next poll iteration.
|
||||||
|
$db->prepare('DELETE FROM collab_tombstones WHERE id > :m')->execute(['m' => $tombstoneMark]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny key/value on the core `settings` table. */
|
||||||
|
function collabSaveSetting(string $key, string $value): void {
|
||||||
|
Database::connect()
|
||||||
|
->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)")
|
||||||
|
->execute(['k' => $key, 'v' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
|
||||||
|
// ── List local mirror ──────────────────────────────────────
|
||||||
|
if ($route === '/api/collab-buckets' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
$rows = $db->query('
|
||||||
|
SELECT uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at
|
||||||
|
FROM collab_buckets
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
')->fetchAll();
|
||||||
|
$out = array_map('collabShapeLocalBucket', $rows);
|
||||||
|
collabJson(['ok' => true, 'buckets' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create on relay, mirror local ──────────────────────────
|
||||||
|
// Resilient path: always create the local bucket first so the user
|
||||||
|
// can keep working even when the relay is down. Then attempt to
|
||||||
|
// upgrade it via the shared helper. On relay failure we queue the
|
||||||
|
// upgrade in collab_bucket_outbox and the next poll retries.
|
||||||
|
if ($route === '/api/collab-buckets' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$color = (string)($input['color'] ?? '#38bdf8');
|
||||||
|
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($name === '') collabJson(['ok' => false, 'error' => 'name required'], 400);
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
|
||||||
|
$db->prepare('INSERT INTO buckets (name, color, position) VALUES (:n, :c, :p)')
|
||||||
|
->execute(['n' => $name, 'c' => $color, 'p' => $maxPos + 1]);
|
||||||
|
$bucketId = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$up = collabAttemptUpgrade($bucketId, $members);
|
||||||
|
if ($up['ok'] ?? false) {
|
||||||
|
collabJson(['ok' => true, 'uid' => $up['uid'], 'bucket_id' => $bucketId]);
|
||||||
|
}
|
||||||
|
// Relay unreachable: queue the upgrade for retry. The bucket
|
||||||
|
// already exists locally so the user sees it immediately.
|
||||||
|
$db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
|
||||||
|
->execute(['b' => $bucketId, 'm' => json_encode($members)]);
|
||||||
|
collabJson([
|
||||||
|
'ok' => true,
|
||||||
|
'queued' => true,
|
||||||
|
'bucket_id' => $bucketId,
|
||||||
|
'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update on relay (owner-only) ───────────────────────────
|
||||||
|
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$uid = $m[1];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'PATCH', $input);
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
collabJson(['ok' => false, 'error' => $res['error'] ?? 'patch failed'], 500);
|
||||||
|
}
|
||||||
|
// Don't mirror the patch locally — the next poll will pick up the new
|
||||||
|
// state from the relay (keeps the relay as source of truth).
|
||||||
|
collabJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete / leave ─────────────────────────────────────────
|
||||||
|
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$uid = $m[1];
|
||||||
|
$res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'DELETE');
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
collabJson(['ok' => false, 'error' => $res['error'] ?? 'delete failed'], 500);
|
||||||
|
}
|
||||||
|
Database::connect()->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
collabDeleteMainBucket($uid);
|
||||||
|
collabJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll: full resync ──────────────────────────────────────
|
||||||
|
if ($route === '/api/collab-buckets/poll' && $method === 'POST') {
|
||||||
|
$db = Database::connect();
|
||||||
|
|
||||||
|
// Phase 5: drain the create/upgrade outbox first. Each queued row
|
||||||
|
// is a "tried to make this local bucket collab but the relay was
|
||||||
|
// down" intent. Retry the upgrade helper; on success drop the
|
||||||
|
// outbox row; on continued failure leave it queued for next poll.
|
||||||
|
$outbox = $db->query('SELECT id, bucket_id, members_json FROM collab_bucket_outbox ORDER BY id ASC')->fetchAll();
|
||||||
|
$outboxProcessed = 0;
|
||||||
|
foreach ($outbox as $o) {
|
||||||
|
$members = json_decode($o['members_json'] ?? '[]', true) ?: [];
|
||||||
|
$up = collabAttemptUpgrade((int)$o['bucket_id'], $members);
|
||||||
|
if ($up['ok'] ?? false) {
|
||||||
|
$db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
|
||||||
|
$outboxProcessed++;
|
||||||
|
} elseif (strpos((string)($up['error'] ?? ''), 'bucket not found') !== false) {
|
||||||
|
// Bucket was deleted locally before the upgrade could fly;
|
||||||
|
// drop the stale outbox entry instead of retrying forever.
|
||||||
|
$db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
|
||||||
|
}
|
||||||
|
// All other failures: leave queued, retry next poll.
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = collabRelayCall('/api/collab-buckets');
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
collabJson(['ok' => false, 'error' => $res['error'] ?? 'poll failed']);
|
||||||
|
}
|
||||||
|
$remote = $res['buckets'] ?? [];
|
||||||
|
$remoteUids = array_column($remote, 'uid');
|
||||||
|
|
||||||
|
// Any local bucket that is no longer on the relay for us = we were
|
||||||
|
// removed (or the bucket was deleted). Drop both the plugin mirror
|
||||||
|
// and the corresponding row in the main buckets table.
|
||||||
|
$staleUids = [];
|
||||||
|
if ($remoteUids) {
|
||||||
|
$ph = implode(',', array_fill(0, count($remoteUids), '?'));
|
||||||
|
$sel = $db->prepare("SELECT uid FROM collab_buckets WHERE uid NOT IN ($ph)");
|
||||||
|
$sel->execute($remoteUids);
|
||||||
|
$staleUids = array_column($sel->fetchAll(), 'uid');
|
||||||
|
if ($staleUids) {
|
||||||
|
$stmt = $db->prepare("DELETE FROM collab_buckets WHERE uid NOT IN ($ph)");
|
||||||
|
$stmt->execute($remoteUids);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$staleUids = array_column($db->query('SELECT uid FROM collab_buckets')->fetchAll(), 'uid');
|
||||||
|
$db->exec('DELETE FROM collab_buckets');
|
||||||
|
}
|
||||||
|
foreach ($staleUids as $uid) {
|
||||||
|
collabDeleteMainBucket($uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert each remote bucket. Preserve local last_seen_at so the dot
|
||||||
|
// state survives across polls.
|
||||||
|
$changed = 0;
|
||||||
|
$upsert = $db->prepare("
|
||||||
|
INSERT INTO collab_buckets
|
||||||
|
(uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
|
||||||
|
VALUES
|
||||||
|
(:uid, :n, :c, :ou, :m, :ca, :ua,
|
||||||
|
COALESCE((SELECT last_seen_at FROM collab_buckets WHERE uid = :uid2), NULL))
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
color = excluded.color,
|
||||||
|
owner_username = excluded.owner_username,
|
||||||
|
members_json = excluded.members_json,
|
||||||
|
created_at = excluded.created_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
");
|
||||||
|
$check = $db->prepare('SELECT updated_at FROM collab_buckets WHERE uid = :u');
|
||||||
|
foreach ($remote as $b) {
|
||||||
|
// Always mirror into main buckets table (idempotent upsert)
|
||||||
|
collabUpsertMainBucket($b['uid'], $b['name'], $b['color']);
|
||||||
|
|
||||||
|
$check->execute(['u' => $b['uid']]);
|
||||||
|
$existing = $check->fetchColumn();
|
||||||
|
if ($existing === ($b['updated_at'] ?? '')) continue; // unchanged, skip plugin-table write
|
||||||
|
$upsert->execute([
|
||||||
|
'uid' => $b['uid'],
|
||||||
|
'n' => $b['name'],
|
||||||
|
'c' => $b['color'],
|
||||||
|
'ou' => $b['owner_username'],
|
||||||
|
'm' => json_encode($b['members'] ?? []),
|
||||||
|
'ca' => $b['created_at'],
|
||||||
|
'ua' => $b['updated_at'],
|
||||||
|
'uid2' => $b['uid'],
|
||||||
|
]);
|
||||||
|
$changed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: card sync ─────────────────────────────────
|
||||||
|
$cardStats = collabSyncCards($db);
|
||||||
|
// If anything changed locally we hint to the frontend to reload
|
||||||
|
$cardsChanged = (
|
||||||
|
$cardStats['pushed']
|
||||||
|
+ $cardStats['pulled']
|
||||||
|
+ $cardStats['deleted_local']
|
||||||
|
+ $cardStats['tombstones_sent']
|
||||||
|
+ $cardStats['conflicts']
|
||||||
|
+ $cardStats['moved']
|
||||||
|
) > 0;
|
||||||
|
|
||||||
|
// ── Phase 4: fold the current lock list into the poll response
|
||||||
|
// so the frontend can decorate cards without a second round-trip.
|
||||||
|
$locks = [];
|
||||||
|
$lockRes = collabRelayCall('/api/collab-locks');
|
||||||
|
if (($lockRes['ok'] ?? false) && is_array($lockRes['locks'] ?? null)) {
|
||||||
|
$locks = $lockRes['locks'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any pending outbox entries still waiting for a reachable relay
|
||||||
|
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
|
||||||
|
|
||||||
|
collabJson([
|
||||||
|
'ok' => true,
|
||||||
|
'self' => collabRelaySettings()['user'],
|
||||||
|
'changed' => $changed,
|
||||||
|
'total' => count($remote),
|
||||||
|
'cards_changed' => $cardsChanged,
|
||||||
|
'card_stats' => $cardStats,
|
||||||
|
'locks' => $locks,
|
||||||
|
'outbox_processed' => $outboxProcessed,
|
||||||
|
'outbox_pending' => $outboxPending,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark a bucket as seen (clears dot + stops pulse next render) ──
|
||||||
|
if ($route === '/api/collab-buckets/mark-seen' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['uid'] ?? ''));
|
||||||
|
if ($uid === '') collabJson(['ok' => false, 'error' => 'uid required'], 400);
|
||||||
|
Database::connect()
|
||||||
|
->prepare("UPDATE collab_buckets SET last_seen_at = datetime('now') WHERE uid = :u")
|
||||||
|
->execute(['u' => $uid]);
|
||||||
|
collabJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proxy: relay tenants (for the create-bucket member picker) ──
|
||||||
|
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
|
||||||
|
// so we wrap it here to match the shape the frontend expects.
|
||||||
|
if ($route === '/api/collab-buckets/relay-tenants' && $method === 'GET') {
|
||||||
|
$res = collabRelayCall('/api/tenants');
|
||||||
|
if (isset($res['ok']) && $res['ok'] === false) {
|
||||||
|
collabJson($res);
|
||||||
|
}
|
||||||
|
collabJson([
|
||||||
|
'ok' => true,
|
||||||
|
'tenants' => $res['tenants'] ?? [],
|
||||||
|
'self' => $res['self'] ?? collabRelaySettings()['user'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 5: upgrade an existing local bucket to collab ───
|
||||||
|
// Keeps the same local buckets.id (so cards stay attached), writes a
|
||||||
|
// new collab_uid onto the row, and creates the mirror on the relay.
|
||||||
|
// On the very next poll the push phase sees every card in that bucket
|
||||||
|
// as dirty (no collab_card_state rows) and uploads them. If the relay
|
||||||
|
// is unreachable, the upgrade is queued in the outbox and retried on
|
||||||
|
// the next poll.
|
||||||
|
if ($route === '/api/collab-buckets/upgrade' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketId = (int)($input['bucket_id'] ?? 0);
|
||||||
|
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($bucketId <= 0) collabJson(['ok' => false, 'error' => 'bucket_id required'], 400);
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$up = collabAttemptUpgrade($bucketId, $members);
|
||||||
|
if ($up['ok'] ?? false) {
|
||||||
|
if (!empty($up['already'])) collabJson(['ok' => false, 'error' => 'bucket is already collaborative'], 400);
|
||||||
|
collabJson(['ok' => true, 'uid' => $up['uid']]);
|
||||||
|
}
|
||||||
|
// Relay unreachable — queue retry
|
||||||
|
$db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
|
||||||
|
->execute(['b' => $bucketId, 'm' => json_encode($members)]);
|
||||||
|
collabJson([
|
||||||
|
'ok' => true,
|
||||||
|
'queued' => true,
|
||||||
|
'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: edit-lock passthrough ─────────────────────────
|
||||||
|
if ($route === '/api/collab-locks' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$res = collabRelayCall('/api/collab-locks', 'POST', $input);
|
||||||
|
// 409 from the relay = another tenant already holds this lock.
|
||||||
|
// Surface the status so the frontend can take the editor over.
|
||||||
|
$code = ($res['ok'] ?? false) ? 200 : (int)($res['status'] ?? 500);
|
||||||
|
if ($code === 0) $code = 500;
|
||||||
|
collabJson($res, $code);
|
||||||
|
}
|
||||||
|
if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$res = collabRelayCall('/api/collab-locks/' . urlencode($m[1]) . '/' . urlencode($m[2]), 'DELETE');
|
||||||
|
collabJson($res);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
/* ── Collab buckets plugin ── */
|
||||||
|
|
||||||
|
/* Decoration for bucket-bar tabs that represent a collab bucket. The core
|
||||||
|
still renders the tab via its normal `.bucket-tab` rule; the plugin only
|
||||||
|
layers a few extras on top: a small 👥 icon swap (done in JS), an unread
|
||||||
|
dot pinned top-right, and a 3-cycle pulse animation when a fresh update
|
||||||
|
arrives. */
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab .collab-tab-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
border: 1px solid var(--t-bg);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab.pulsing {
|
||||||
|
animation: collab-bucket-pulse 1.2s ease-in-out 3;
|
||||||
|
}
|
||||||
|
/* Keep the 3-cycle cadence regardless of global animation-speed override */
|
||||||
|
body.anim-fast .bucket-tab.bucket-tab-collab.pulsing,
|
||||||
|
body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
|
||||||
|
animation: collab-bucket-pulse 1.2s ease-in-out 3 !important;
|
||||||
|
animation-duration: 1.2s !important;
|
||||||
|
}
|
||||||
|
@keyframes collab-bucket-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-danger) 60%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--t-danger) 0%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Create / edit modal (used by the + Bucket button when the plugin
|
||||||
|
is loaded) ── */
|
||||||
|
.collab-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 240;
|
||||||
|
background: var(--t-overlay, rgba(0,0,0,0.6));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
}
|
||||||
|
.collab-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.collab-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.collab-modal-close:hover { color: var(--t-danger); border-color: var(--t-danger); }
|
||||||
|
.collab-modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.collab-field { display: block; margin-bottom: 14px; }
|
||||||
|
.collab-field:last-child { margin-bottom: 0; }
|
||||||
|
.collab-field.hidden { display: none; }
|
||||||
|
.collab-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.collab-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.collab-input:focus { border-color: var(--t-accent); }
|
||||||
|
.collab-color {
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-toggle-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.collab-toggle-row input { accent-color: var(--t-accent); cursor: pointer; }
|
||||||
|
.collab-toggle-row:hover { color: var(--t-bright); }
|
||||||
|
|
||||||
|
.collab-members-wrap { margin-top: 10px; }
|
||||||
|
.collab-members {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.collab-members-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.collab-member-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
border-radius: 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.collab-member-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.collab-member-row input { cursor: pointer; accent-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.collab-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-modal-status {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
.collab-btn-cancel,
|
||||||
|
.collab-btn-submit {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
color: var(--t-muted);
|
||||||
|
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.collab-btn-cancel:hover {
|
||||||
|
color: var(--t-danger);
|
||||||
|
border-color: var(--t-danger);
|
||||||
|
}
|
||||||
|
.collab-btn-submit {
|
||||||
|
color: var(--t-accent);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.collab-btn-submit:hover {
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-readonly {
|
||||||
|
display: block;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collab-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phase 4: exclusive edit-lock indicator on board cards ──
|
||||||
|
Locked cards get a frosted overlay + centered lock icon. The card
|
||||||
|
content underneath stays visible enough to read but is clearly
|
||||||
|
"off-limits" — the cue is intentionally heavier than the old badge
|
||||||
|
so the user can't miss it. */
|
||||||
|
.board-card.collab-locked {
|
||||||
|
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 70%, transparent);
|
||||||
|
}
|
||||||
|
.board-card.collab-locked > *:not(.collab-lock-badge) {
|
||||||
|
filter: blur(2px);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: filter 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.collab-lock-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--t-accent2);
|
||||||
|
background: color-mix(in srgb, var(--t-surface) 60%, transparent);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
-webkit-backdrop-filter: blur(1px);
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.collab-lock-badge i {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.collab-lock-badge span {
|
||||||
|
color: var(--t-text);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor takeover (foreign lock arrived while editing) ── */
|
||||||
|
.collab-readonly-modal #inp-title,
|
||||||
|
.collab-readonly-modal #editor-body {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.collab-readonly-modal #editor-body {
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 6%, transparent) !important;
|
||||||
|
}
|
||||||
|
.collab-save-blocked {
|
||||||
|
opacity: 0.4 !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 12%, transparent);
|
||||||
|
border-bottom: 1px solid var(--t-danger);
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner i {
|
||||||
|
color: var(--t-danger);
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner b { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */
|
||||||
|
.collab-conflict-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 220;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px 10px 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-danger);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
transition: opacity 0.4s;
|
||||||
|
}
|
||||||
|
.collab-conflict-toast i.ph-warning { color: var(--t-danger); flex-shrink: 0; }
|
||||||
|
.collab-conflict-toast b { color: var(--t-danger); font-weight: 700; }
|
||||||
|
.collab-conflict-toast.fade-out { opacity: 0; }
|
||||||
|
/* Queued variant reuses the layout but switches the danger accents for
|
||||||
|
a neutral info color — used for "relay was down, bucket is queued" and
|
||||||
|
"queued bucket finally synced" messages. */
|
||||||
|
.collab-conflict-toast.collab-queued-toast {
|
||||||
|
border-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-queued-toast i.ph-cloud-arrow-up {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-conflict-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.collab-conflict-close:hover { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Lock toast: same shape as the conflict toast but accented with the
|
||||||
|
collab/lock color. Reused for both the editor-takeover notification
|
||||||
|
and the "your push was queued because someone else is editing" toast. */
|
||||||
|
.collab-conflict-toast.collab-lock-toast {
|
||||||
|
border-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-lock-toast i.ph-lock-simple-fill {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-lock-toast b {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
// ── Collab Buckets Plugin Frontend ──
|
||||||
|
//
|
||||||
|
// Phase 1+2 shape (post-refactor): collab buckets are first-class bucket
|
||||||
|
// rows in the standard bucket bar. The plugin just owns the sync + the
|
||||||
|
// visual unread-state decoration:
|
||||||
|
//
|
||||||
|
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
|
||||||
|
// mirrors each remote bucket into the main `buckets` table so the core
|
||||||
|
// bucket bar renders it automatically.
|
||||||
|
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
||||||
|
// the core + Bucket button calls so we can pop a richer "new bucket"
|
||||||
|
// modal with an optional "collaborative" checkbox + member picker.
|
||||||
|
// - Watches #bucket-bar via MutationObserver. After every render, finds
|
||||||
|
// tabs whose data-bucket id corresponds to a collab bucket and attaches
|
||||||
|
// a pulse animation + red unread dot if the bucket has unread state.
|
||||||
|
// - Delegated click handler on #bucket-bar calls mark-seen for collab tabs.
|
||||||
|
//
|
||||||
|
// The plugin never touches the header anymore. No more standalone tiles.
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) throw new Error('[collab-buckets] window.hyprcrd missing');
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// uid -> collab bucket meta (from /api/collab-buckets)
|
||||||
|
let collabByUid = new Map();
|
||||||
|
let pollHandle = null;
|
||||||
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
// Phase 4: edit locks (now exclusive — server-enforced)
|
||||||
|
// card_uid -> { bucket_uid, holder_username, expires_at }
|
||||||
|
let collabLocks = new Map();
|
||||||
|
// Tracks the editor session: { bucket_uid, card_uid, cardId, hbTimer,
|
||||||
|
// inputCleanup, acquired, takeover }
|
||||||
|
let editorSession = null;
|
||||||
|
const LOCK_HEARTBEAT_MS = 30000;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading / polling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadCollabMeta() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/collab-buckets').then(r => r.json());
|
||||||
|
if (!res?.ok) return;
|
||||||
|
const next = new Map();
|
||||||
|
for (const b of res.buckets || []) next.set(b.uid, b);
|
||||||
|
collabByUid = next;
|
||||||
|
// Expose for other plugin code + for debugging
|
||||||
|
window._collabBucketsByUid = collabByUid;
|
||||||
|
decorateBucketBar();
|
||||||
|
} catch (err) { /* silent; relay may be offline */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
||||||
|
if (!res?.ok) return;
|
||||||
|
if (res.self) window._collabSelf = res.self;
|
||||||
|
// Reload if:
|
||||||
|
// * a bucket changed (metadata / members)
|
||||||
|
// * the bucket set shrunk or grew
|
||||||
|
// * card sync pushed / pulled / deleted anything (Phase 3)
|
||||||
|
const bucketChanged = res.changed > 0 || res.total !== collabByUid.size;
|
||||||
|
const cardsChanged = !!res.cards_changed;
|
||||||
|
if (bucketChanged || cardsChanged) {
|
||||||
|
await loadCollabMeta();
|
||||||
|
if (typeof window._reloadAll === 'function') await window._reloadAll();
|
||||||
|
}
|
||||||
|
// If LWW clobbered local edits, the backend stashed them in a local
|
||||||
|
// "Conflicts" bucket — surface that visibly so the user knows.
|
||||||
|
const conflicts = res.card_stats?.conflicts || 0;
|
||||||
|
if (conflicts > 0) {
|
||||||
|
showConflictToast(conflicts);
|
||||||
|
}
|
||||||
|
// Phase 5: if a queued bucket create finally pushed through, tell
|
||||||
|
// the user. Retry failures stay silent.
|
||||||
|
if ((res.outbox_processed || 0) > 0) {
|
||||||
|
const n = res.outbox_processed;
|
||||||
|
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
||||||
|
}
|
||||||
|
// Phase 4: edit-lock list comes back with the poll
|
||||||
|
if (Array.isArray(res.locks)) {
|
||||||
|
const next = new Map();
|
||||||
|
for (const l of res.locks) next.set(l.card_uid, l);
|
||||||
|
collabLocks = next;
|
||||||
|
window._collabLocks = collabLocks;
|
||||||
|
decorateBoardLocks();
|
||||||
|
// If the editor is currently open on a card that just got
|
||||||
|
// locked by someone else, take over immediately. Covers the
|
||||||
|
// race where two users started typing nearly simultaneously
|
||||||
|
// and the relay arbitration only resolves on the next poll.
|
||||||
|
if (editorSession && !editorSession.takeover) {
|
||||||
|
const lock = collabLocks.get(editorSession.card_uid);
|
||||||
|
const self = window._collabSelf || null;
|
||||||
|
if (lock && lock.holder_username && lock.holder_username !== self) {
|
||||||
|
enterEditorTakeover(lock.holder_username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The push phase couldn't deliver because someone else holds an
|
||||||
|
// exclusive lock on a dirty card. Surface so the user knows their
|
||||||
|
// edit isn't lost — it's just queued.
|
||||||
|
const blocked = res.card_stats?.blocked || 0;
|
||||||
|
if (blocked > 0) {
|
||||||
|
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
|
||||||
|
showBlockedToast(by);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConflictToast(n) {
|
||||||
|
const existing = document.getElementById('collab-conflict-toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'collab-conflict-toast';
|
||||||
|
toast.className = 'collab-conflict-toast';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="ph ph-warning text-sm"></i>
|
||||||
|
<span>${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the <b>Conflicts</b> bucket.</span>
|
||||||
|
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
|
||||||
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Informational toast — used when the relay is unreachable and a bucket
|
||||||
|
* create was queued, or when the outbox retry succeeds later. */
|
||||||
|
function showQueuedToast(message) {
|
||||||
|
const existing = document.getElementById('collab-queued-toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'collab-queued-toast';
|
||||||
|
toast.className = 'collab-conflict-toast collab-queued-toast';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="ph ph-cloud-arrow-up text-sm"></i>
|
||||||
|
<span>${escapeHtml(message)}</span>
|
||||||
|
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
|
||||||
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For every tab in #bucket-bar whose bucket has a collab_uid, apply
|
||||||
|
* collab-specific classes (.bucket-tab-collab, .has-unread, .pulsing)
|
||||||
|
* and inject an unread dot. Runs after every core re-render via the
|
||||||
|
* MutationObserver below.
|
||||||
|
*/
|
||||||
|
function decorateBucketBar() {
|
||||||
|
const bar = document.getElementById('bucket-bar');
|
||||||
|
if (!bar || !window.state?.buckets) return;
|
||||||
|
|
||||||
|
// Build bucket-id -> row map so we can look up collab_uid
|
||||||
|
const byId = new Map();
|
||||||
|
for (const b of window.state.buckets) byId.set(String(b.id), b);
|
||||||
|
|
||||||
|
for (const tab of bar.querySelectorAll('.bucket-tab[data-bucket]')) {
|
||||||
|
const bid = tab.dataset.bucket;
|
||||||
|
const row = byId.get(bid);
|
||||||
|
if (!row || !row.collab_uid) continue;
|
||||||
|
|
||||||
|
tab.classList.add('bucket-tab-collab');
|
||||||
|
tab.dataset.collabUid = row.collab_uid;
|
||||||
|
|
||||||
|
// Replace the default folder icon with the "users" icon so the tab
|
||||||
|
// reads as collaborative at a glance. We query by class and swap
|
||||||
|
// the phosphor icon class set. Safe to run on every render — the
|
||||||
|
// "icon swap" is idempotent.
|
||||||
|
const icon = tab.querySelector('i.ph');
|
||||||
|
if (icon && !icon.classList.contains('ph-users-three')) {
|
||||||
|
icon.classList.remove('ph-folder');
|
||||||
|
icon.classList.add('ph-users-three');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread state
|
||||||
|
const meta = collabByUid.get(row.collab_uid);
|
||||||
|
const wasUnread = tab.classList.contains('has-unread');
|
||||||
|
const isUnread = !!meta?.has_unread;
|
||||||
|
tab.classList.toggle('has-unread', isUnread);
|
||||||
|
|
||||||
|
// Inject / remove dot element
|
||||||
|
let dot = tab.querySelector('.collab-tab-dot');
|
||||||
|
if (isUnread && !dot) {
|
||||||
|
dot = document.createElement('span');
|
||||||
|
dot.className = 'collab-tab-dot';
|
||||||
|
tab.appendChild(dot);
|
||||||
|
} else if (!isUnread && dot) {
|
||||||
|
dot.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh unread -> pulse 3x. JS strips the class after 3.7s so
|
||||||
|
// subsequent decorate-calls don't retrigger mid-animation.
|
||||||
|
if (isUnread && !wasUnread) {
|
||||||
|
tab.classList.add('pulsing');
|
||||||
|
setTimeout(() => tab.classList.remove('pulsing'), 3700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark-seen on click (delegated, fires after core's click handler) ──
|
||||||
|
|
||||||
|
function setupClickInterceptor() {
|
||||||
|
const bar = document.getElementById('bucket-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
bar.addEventListener('click', async (e) => {
|
||||||
|
const tab = e.target.closest('.bucket-tab[data-bucket]');
|
||||||
|
if (!tab) return;
|
||||||
|
const uid = tab.dataset.collabUid;
|
||||||
|
if (!uid) return;
|
||||||
|
// Clear unread locally immediately for snappy feedback
|
||||||
|
const meta = collabByUid.get(uid);
|
||||||
|
if (meta) meta.has_unread = false;
|
||||||
|
tab.classList.remove('has-unread', 'pulsing');
|
||||||
|
tab.querySelector('.collab-tab-dot')?.remove();
|
||||||
|
// Persist to the plugin backend
|
||||||
|
hyprcrd.api('api/collab-buckets/mark-seen', { method: 'POST', body: { uid } }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MutationObserver: redecorate on every bar re-render ───────────
|
||||||
|
|
||||||
|
function setupBarObserver() {
|
||||||
|
const bar = document.getElementById('bucket-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
const obs = new MutationObserver(() => decorateBucketBar());
|
||||||
|
obs.observe(bar, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create dialog (hooked by core's + Bucket button) ──────────────
|
||||||
|
|
||||||
|
async function fetchRelayTenants() {
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/collab-buckets/relay-tenants');
|
||||||
|
return res?.ok ? res : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the user clicks the core "+ Bucket" button. Returns true when
|
||||||
|
* a bucket was created (so core can reload) and false / null when canceled.
|
||||||
|
*/
|
||||||
|
async function createBucketDialog() {
|
||||||
|
// Lazy-load the relay tenants so a local-only bucket doesn't pay for
|
||||||
|
// the round trip when the user just wants a simple bucket.
|
||||||
|
const relayP = fetchRelayTenants();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'collab-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="collab-modal">
|
||||||
|
<div class="collab-modal-header">
|
||||||
|
<i class="ph ph-folder-plus text-t-accent"></i>
|
||||||
|
<span class="font-pixel text-xs text-t-accent">new bucket</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-body">
|
||||||
|
<label class="collab-field">
|
||||||
|
<span class="collab-label">Name</span>
|
||||||
|
<input type="text" class="collab-input" id="__cb_name" placeholder="bucket name...">
|
||||||
|
</label>
|
||||||
|
<label class="collab-field">
|
||||||
|
<span class="collab-label">Color</span>
|
||||||
|
<input type="color" class="collab-color" id="__cb_color" value="#64748b">
|
||||||
|
</label>
|
||||||
|
<label class="collab-field collab-toggle-row">
|
||||||
|
<input type="checkbox" id="__cb_collab">
|
||||||
|
<span>Collaborative — share with relay users</span>
|
||||||
|
</label>
|
||||||
|
<div class="collab-field collab-members-wrap hidden" id="__cb_members_wrap">
|
||||||
|
<span class="collab-label">Members</span>
|
||||||
|
<div class="collab-members" id="__cb_members_list">
|
||||||
|
<div class="collab-members-empty">loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-footer">
|
||||||
|
<div class="collab-modal-status" id="__cb_status"></div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||||
|
<button class="collab-btn-submit" type="button">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const done = (created) => { overlay.remove(); resolve(created); };
|
||||||
|
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||||
|
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||||
|
document.addEventListener('keydown', function esc(e) {
|
||||||
|
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const collabEl = overlay.querySelector('#__cb_collab');
|
||||||
|
const membersWrap = overlay.querySelector('#__cb_members_wrap');
|
||||||
|
const membersList = overlay.querySelector('#__cb_members_list');
|
||||||
|
const status = overlay.querySelector('#__cb_status');
|
||||||
|
|
||||||
|
let peers = [];
|
||||||
|
let tenantsLoaded = false;
|
||||||
|
|
||||||
|
collabEl.addEventListener('change', async () => {
|
||||||
|
if (!collabEl.checked) {
|
||||||
|
membersWrap.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
membersWrap.classList.remove('hidden');
|
||||||
|
if (tenantsLoaded) return;
|
||||||
|
const relay = await relayP;
|
||||||
|
tenantsLoaded = true;
|
||||||
|
if (!relay) {
|
||||||
|
membersList.innerHTML = '<div class="collab-members-empty">could not reach the relay — uncheck collaborative to create a local bucket</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const self = relay.self || '';
|
||||||
|
peers = (relay.tenants || []).filter(t => (t.username || t) !== self);
|
||||||
|
if (!peers.length) {
|
||||||
|
membersList.innerHTML = '<div class="collab-members-empty">no other relay users</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
membersList.innerHTML = peers.map(t => {
|
||||||
|
const u = typeof t === 'string' ? t : (t.username || '');
|
||||||
|
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`;
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||||
|
const name = overlay.querySelector('#__cb_name').value.trim();
|
||||||
|
const color = overlay.querySelector('#__cb_color').value;
|
||||||
|
const isCollab = collabEl.checked;
|
||||||
|
if (!name) { status.textContent = 'name required'; status.style.color = 'var(--t-danger)'; return; }
|
||||||
|
status.textContent = 'creating…';
|
||||||
|
status.style.color = 'var(--t-dim)';
|
||||||
|
try {
|
||||||
|
if (isCollab) {
|
||||||
|
const members = [...overlay.querySelectorAll('#__cb_members_list input:checked')].map(c => c.value);
|
||||||
|
const res = await hyprcrd.api('api/collab-buckets', { method: 'POST', body: { name, color, members } });
|
||||||
|
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||||
|
// Relay was unreachable; the bucket exists locally and the
|
||||||
|
// upgrade is queued. Show a toast so the user knows.
|
||||||
|
if (res.queued) {
|
||||||
|
showQueuedToast('Bucket created locally — will sync to the relay on the next successful poll.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular bucket: POST /api/buckets (core route), then PATCH color.
|
||||||
|
const created = await hyprcrd.api('api/buckets', { method: 'POST', body: { name } });
|
||||||
|
if (created?.id && color) {
|
||||||
|
await hyprcrd.api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadCollabMeta();
|
||||||
|
done(true);
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = String(err?.message || err);
|
||||||
|
status.style.color = 'var(--t-danger)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for core's + Bucket handler
|
||||||
|
window._collabBucketsCreateDialog = createBucketDialog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit dialog for an existing collab bucket. Shown by the core bucket
|
||||||
|
* context menu's "Members & owner…" item. The current owner sees a
|
||||||
|
* member picker + an owner dropdown; other members see everything as
|
||||||
|
* read-only labels since only the owner can push updates. Returns true
|
||||||
|
* when the PATCH succeeded so core can reload.
|
||||||
|
*/
|
||||||
|
async function editBucketDialog(collabUid) {
|
||||||
|
const bucket = collabByUid.get(collabUid);
|
||||||
|
if (!bucket) return false;
|
||||||
|
const relay = await fetchRelayTenants();
|
||||||
|
if (!relay) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'collab buckets',
|
||||||
|
message: 'Could not reach the relay.',
|
||||||
|
okLabel: 'Ok', cancelLabel: '',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const self = relay.self || '';
|
||||||
|
const allTenants = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || ''));
|
||||||
|
const peers = allTenants.filter(u => u && u !== self);
|
||||||
|
const isOwner = bucket.owner_username === self;
|
||||||
|
const currentMembers = new Set((bucket.members || []).filter(u => u && u !== self));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'collab-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="collab-modal">
|
||||||
|
<div class="collab-modal-header">
|
||||||
|
<i class="ph ph-users-three text-t-accent"></i>
|
||||||
|
<span class="font-pixel text-xs text-t-accent">${escapeHtml(bucket.name)} — members</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-body">
|
||||||
|
<div class="collab-field">
|
||||||
|
<span class="collab-label">Owner</span>
|
||||||
|
${isOwner
|
||||||
|
? `<select class="collab-input" id="__cb_owner">
|
||||||
|
<option value="${escapeHtml(self)}">${escapeHtml(self)} (you)</option>
|
||||||
|
${[...currentMembers].sort().map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<div class="collab-hint">Transferring will hand over all edit rights. You stay a member.</div>`
|
||||||
|
: `<div class="collab-readonly">${escapeHtml(bucket.owner_username)}</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="collab-field">
|
||||||
|
<span class="collab-label">Members (${peers.length} available)</span>
|
||||||
|
${isOwner
|
||||||
|
? `<div class="collab-members" id="__cb_edit_members">
|
||||||
|
${peers.map(u => {
|
||||||
|
const checked = currentMembers.has(u) ? 'checked' : '';
|
||||||
|
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}" ${checked}><span>${escapeHtml(u)}</span></label>`;
|
||||||
|
}).join('') || '<div class="collab-members-empty">no other relay users</div>'}
|
||||||
|
</div>`
|
||||||
|
: `<div class="collab-members">
|
||||||
|
${(bucket.members || []).map(u => `<div class="collab-member-row"><span>${escapeHtml(u)}${u === self ? ' (you)' : ''}</span></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="collab-hint">Only the owner can change members or transfer ownership.</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-footer">
|
||||||
|
<div class="collab-modal-status" id="__cb_status"></div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-btn-cancel" type="button">${isOwner ? 'Cancel' : 'Close'}</button>
|
||||||
|
${isOwner ? `<button class="collab-btn-submit" type="button">Save</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const done = (changed) => { overlay.remove(); resolve(changed); };
|
||||||
|
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||||
|
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||||
|
document.addEventListener('keydown', function esc(e) {
|
||||||
|
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOwner) return; // read-only view; no submit handler
|
||||||
|
|
||||||
|
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||||
|
const newOwner = overlay.querySelector('#__cb_owner').value;
|
||||||
|
const members = [...overlay.querySelectorAll('#__cb_edit_members input:checked')].map(c => c.value);
|
||||||
|
const status = overlay.querySelector('#__cb_status');
|
||||||
|
const payload = { members };
|
||||||
|
if (newOwner && newOwner !== self) payload.owner = newOwner;
|
||||||
|
status.textContent = 'saving…';
|
||||||
|
status.style.color = 'var(--t-dim)';
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), {
|
||||||
|
method: 'PATCH', body: payload,
|
||||||
|
});
|
||||||
|
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||||
|
await pollOnce();
|
||||||
|
await loadCollabMeta();
|
||||||
|
done(true);
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = String(err?.message || err);
|
||||||
|
status.style.color = 'var(--t-danger)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window._collabBucketsEditDialog = editBucketDialog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade dialog for turning an existing local bucket into a collab
|
||||||
|
* bucket. The core bucket context menu shows "Make collaborative…" for
|
||||||
|
* non-collab buckets when the plugin is loaded. The dialog just picks
|
||||||
|
* members — name/color come from the existing bucket and are reused.
|
||||||
|
* Returns true on success so core can reload.
|
||||||
|
*/
|
||||||
|
async function upgradeBucketDialog(localBucketId) {
|
||||||
|
const bucket = window.state?.buckets?.find(b => String(b.id) === String(localBucketId));
|
||||||
|
if (!bucket || bucket.collab_uid) return false;
|
||||||
|
const relay = await fetchRelayTenants();
|
||||||
|
if (!relay) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'collab buckets',
|
||||||
|
message: 'Could not reach the relay. Check the relay settings first.',
|
||||||
|
okLabel: 'Ok', cancelLabel: '',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const self = relay.self || '';
|
||||||
|
const peers = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')).filter(u => u && u !== self);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'collab-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="collab-modal">
|
||||||
|
<div class="collab-modal-header">
|
||||||
|
<i class="ph ph-users-three text-t-accent"></i>
|
||||||
|
<span class="font-pixel text-xs text-t-accent">make "${escapeHtml(bucket.name)}" collaborative</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-body">
|
||||||
|
<div class="collab-field">
|
||||||
|
<div class="collab-hint">This bucket and all its cards will be shared with the members you pick. Existing cards are pushed on the next sync (~30s). You stay the owner.</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-field">
|
||||||
|
<span class="collab-label">Members (${peers.length} available)</span>
|
||||||
|
${peers.length === 0
|
||||||
|
? '<div class="collab-members-empty">no other relay users</div>'
|
||||||
|
: `<div class="collab-members" id="__cb_up_members">
|
||||||
|
${peers.map(u => `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`).join('')}
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collab-modal-footer">
|
||||||
|
<div class="collab-modal-status" id="__cb_status"></div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||||
|
<button class="collab-btn-submit" type="button">Make collaborative</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const done = (changed) => { overlay.remove(); resolve(changed); };
|
||||||
|
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||||
|
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||||
|
document.addEventListener('keydown', function esc(e) {
|
||||||
|
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||||
|
const status = overlay.querySelector('#__cb_status');
|
||||||
|
const members = [...overlay.querySelectorAll('#__cb_up_members input:checked')].map(c => c.value);
|
||||||
|
status.textContent = 'upgrading…';
|
||||||
|
status.style.color = 'var(--t-dim)';
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/collab-buckets/upgrade', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { bucket_id: localBucketId, members },
|
||||||
|
});
|
||||||
|
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||||
|
await pollOnce();
|
||||||
|
await loadCollabMeta();
|
||||||
|
done(true);
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = String(err?.message || err);
|
||||||
|
status.style.color = 'var(--t-danger)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window._collabBucketsUpgradeDialog = upgradeBucketDialog;
|
||||||
|
|
||||||
|
// Expose helper so the core bucket context menu can route collab-bucket
|
||||||
|
// rename / color / delete through the plugin endpoints.
|
||||||
|
window._collabBucketsPatch = async function (collabUid, payload) {
|
||||||
|
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'PATCH', body: payload });
|
||||||
|
};
|
||||||
|
window._collabBucketsDelete = async function (collabUid) {
|
||||||
|
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Phase 4: board-card lock decoration ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every rendered board card and add / remove a lock indicator if
|
||||||
|
* another user currently holds an edit lock on it. Idempotent — safe to
|
||||||
|
* call on every render / lock-poll.
|
||||||
|
*/
|
||||||
|
function decorateBoardLocks() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board || !window.state?.cards) return;
|
||||||
|
// Build card_id -> card row lookup so we can resolve relay_uid
|
||||||
|
const byId = new Map();
|
||||||
|
for (const c of window.state.cards) byId.set(String(c.id), c);
|
||||||
|
|
||||||
|
const self = window._collabSelf || null;
|
||||||
|
for (const el of board.querySelectorAll('.board-card[data-id]')) {
|
||||||
|
const card = byId.get(el.dataset.id);
|
||||||
|
const lock = card && card.relay_uid ? collabLocks.get(card.relay_uid) : null;
|
||||||
|
// Ignore our own lock — no point showing the user their own editing state
|
||||||
|
const foreign = lock && lock.holder_username && lock.holder_username !== self;
|
||||||
|
el.classList.toggle('collab-locked', !!foreign);
|
||||||
|
let badge = el.querySelector('.collab-lock-badge');
|
||||||
|
if (foreign) {
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('div');
|
||||||
|
badge.className = 'collab-lock-badge';
|
||||||
|
el.appendChild(badge);
|
||||||
|
}
|
||||||
|
badge.innerHTML = `
|
||||||
|
<i class="ph ph-lock-simple-fill"></i>
|
||||||
|
<span>${escapeHtml(lock.holder_username)}</span>
|
||||||
|
`;
|
||||||
|
badge.title = `Locked by ${lock.holder_username}`;
|
||||||
|
} else if (badge) {
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-decorate whenever the board re-renders (reuses the existing #board
|
||||||
|
// MutationObserver pattern).
|
||||||
|
function setupBoardObserver() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
const obs = new MutationObserver(() => decorateBoardLocks());
|
||||||
|
obs.observe(board, { childList: true, subtree: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: heartbeat the lock while editing a collab card ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a card to its collab bucket_uid + relay_uid. Returns null for
|
||||||
|
* non-collab cards (which don't get a lock).
|
||||||
|
*/
|
||||||
|
function collabIdentityOf(card) {
|
||||||
|
if (!card || !card.bucket_id) return null;
|
||||||
|
const bucket = window.state?.buckets?.find(b => String(b.id) === String(card.bucket_id));
|
||||||
|
if (!bucket?.collab_uid) return null;
|
||||||
|
if (!card.relay_uid) return null; // not yet pushed → no lock
|
||||||
|
return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acquire (or refresh) the exclusive lock. Returns:
|
||||||
|
* { ok: true } on success
|
||||||
|
* { ok: false, locked_by: '…' } when another tenant already holds it (409)
|
||||||
|
* { ok: false } on transport / unknown error */
|
||||||
|
async function tryAcquireLock(bucket_uid, card_uid) {
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/collab-locks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { bucket_uid, card_uid },
|
||||||
|
});
|
||||||
|
if (res?.ok) return { ok: true };
|
||||||
|
if (res?.locked_by) return { ok: false, locked_by: res.locked_by };
|
||||||
|
return { ok: false };
|
||||||
|
} catch {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseLock(bucket_uid, card_uid) {
|
||||||
|
try {
|
||||||
|
await hyprcrd.api(
|
||||||
|
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-keystroke lock: sets up one-shot input listeners on the editor
|
||||||
|
* inputs. The first input event tries to acquire the exclusive lock; on
|
||||||
|
* success we start the heartbeat, on failure we slap the editor into
|
||||||
|
* takeover mode. */
|
||||||
|
function armFirstKeystrokeLock() {
|
||||||
|
if (!editorSession) return;
|
||||||
|
const inputs = [document.getElementById('inp-title'), document.getElementById('editor-body')].filter(Boolean);
|
||||||
|
let fired = false;
|
||||||
|
const onInput = async () => {
|
||||||
|
if (fired) return;
|
||||||
|
fired = true;
|
||||||
|
// Drop the listeners — heartbeat or takeover takes over from here
|
||||||
|
for (const el of inputs) el.removeEventListener('input', onInput);
|
||||||
|
if (!editorSession) return;
|
||||||
|
const { bucket_uid, card_uid } = editorSession;
|
||||||
|
const res = await tryAcquireLock(bucket_uid, card_uid);
|
||||||
|
if (!editorSession) return; // editor closed during the await
|
||||||
|
if (res.ok) {
|
||||||
|
editorSession.acquired = true;
|
||||||
|
editorSession.hbTimer = setInterval(() => tryAcquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
|
||||||
|
} else {
|
||||||
|
enterEditorTakeover(res.locked_by || 'someone');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const el of inputs) el.addEventListener('input', onInput);
|
||||||
|
editorSession.inputCleanup = () => {
|
||||||
|
for (const el of inputs) el.removeEventListener('input', onInput);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch the editor to read-only takeover mode. The user can still see /
|
||||||
|
* copy their text but the inputs are disabled and save is blocked. */
|
||||||
|
function enterEditorTakeover(holder) {
|
||||||
|
if (!editorSession || editorSession.takeover) return;
|
||||||
|
editorSession.takeover = true;
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const title = document.getElementById('inp-title');
|
||||||
|
const body = document.getElementById('editor-body');
|
||||||
|
if (title) title.disabled = true;
|
||||||
|
if (body) body.disabled = true;
|
||||||
|
modal?.classList.add('collab-readonly-modal');
|
||||||
|
|
||||||
|
// Block save buttons by capturing clicks before the editor handler.
|
||||||
|
// stopImmediatePropagation also blocks any sibling listeners registered
|
||||||
|
// on the same element (the editor's own click handler) and skips both
|
||||||
|
// target + bubble phases for ancestors.
|
||||||
|
const saveIds = ['modal-save', 'modal-save-view', 'modal-save-close'];
|
||||||
|
const blockers = saveIds.map(id => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (!btn) return null;
|
||||||
|
const handler = (e) => {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
btn.addEventListener('click', handler, true); // capture phase
|
||||||
|
btn.setAttribute('disabled', 'disabled');
|
||||||
|
btn.classList.add('collab-save-blocked');
|
||||||
|
return { btn, handler };
|
||||||
|
}).filter(Boolean);
|
||||||
|
editorSession.saveBlockers = blockers;
|
||||||
|
|
||||||
|
// Also intercept Ctrl/Cmd+S — app.js binds this directly to saveCard/
|
||||||
|
// saveOnly without going through the buttons we just disabled.
|
||||||
|
const keyBlocker = (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', keyBlocker, true);
|
||||||
|
editorSession.keyBlocker = keyBlocker;
|
||||||
|
|
||||||
|
// Banner inside the modal explaining the situation
|
||||||
|
let banner = document.getElementById('collab-takeover-banner');
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'collab-takeover-banner';
|
||||||
|
banner.className = 'collab-takeover-banner';
|
||||||
|
const inner = document.querySelector('#modal .modal-inner') || document.querySelector('#modal > div') || modal;
|
||||||
|
inner?.prepend(banner);
|
||||||
|
}
|
||||||
|
banner.innerHTML = `
|
||||||
|
<i class="ph ph-lock-simple-fill"></i>
|
||||||
|
<span><b>Locked by ${escapeHtml(holder)}.</b> This card was locked by another user while you were typing. Copy your text out — saving is disabled.</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Also show a one-off toast so the user can't miss it
|
||||||
|
showLockToast(holder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitEditorTakeover() {
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const title = document.getElementById('inp-title');
|
||||||
|
const body = document.getElementById('editor-body');
|
||||||
|
if (title) title.disabled = false;
|
||||||
|
if (body) body.disabled = false;
|
||||||
|
modal?.classList.remove('collab-readonly-modal');
|
||||||
|
document.getElementById('collab-takeover-banner')?.remove();
|
||||||
|
if (editorSession?.saveBlockers) {
|
||||||
|
for (const { btn, handler } of editorSession.saveBlockers) {
|
||||||
|
btn.removeEventListener('click', handler, true);
|
||||||
|
btn.removeAttribute('disabled');
|
||||||
|
btn.classList.remove('collab-save-blocked');
|
||||||
|
}
|
||||||
|
editorSession.saveBlockers = null;
|
||||||
|
}
|
||||||
|
if (editorSession?.keyBlocker) {
|
||||||
|
window.removeEventListener('keydown', editorSession.keyBlocker, true);
|
||||||
|
editorSession.keyBlocker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLockToast(holder) {
|
||||||
|
const existing = document.getElementById('collab-lock-toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'collab-lock-toast';
|
||||||
|
toast.className = 'collab-conflict-toast collab-lock-toast';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="ph ph-lock-simple-fill"></i>
|
||||||
|
<span><b>Locked by ${escapeHtml(holder)}.</b> Copy your text — saving is blocked.</span>
|
||||||
|
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
|
||||||
|
`;
|
||||||
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
|
||||||
|
toast.classList.add('fade-out');
|
||||||
|
setTimeout(() => toast.remove(), 400);
|
||||||
|
});
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBlockedToast(by) {
|
||||||
|
const existing = document.getElementById('collab-blocked-toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'collab-blocked-toast';
|
||||||
|
toast.className = 'collab-conflict-toast collab-lock-toast';
|
||||||
|
const who = by.length ? by.join(', ') : 'another user';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="ph ph-lock-simple-fill"></i>
|
||||||
|
<span>Your edit is queued — <b>${escapeHtml(who)}</b> is currently editing. It'll sync when they're done.</span>
|
||||||
|
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
|
||||||
|
`;
|
||||||
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
|
||||||
|
toast.classList.add('fade-out');
|
||||||
|
setTimeout(() => toast.remove(), 400);
|
||||||
|
});
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down everything we own for the active editor session. Called both
|
||||||
|
* on editor-close and on plugin reload. */
|
||||||
|
function endEditorSession() {
|
||||||
|
if (!editorSession) return;
|
||||||
|
const { hbTimer, inputCleanup, acquired, bucket_uid, card_uid } = editorSession;
|
||||||
|
if (hbTimer) clearInterval(hbTimer);
|
||||||
|
if (typeof inputCleanup === 'function') inputCleanup();
|
||||||
|
if (acquired) releaseLock(bucket_uid, card_uid);
|
||||||
|
exitEditorTakeover();
|
||||||
|
editorSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hyprcrd:editor-open', (e) => {
|
||||||
|
endEditorSession();
|
||||||
|
const card = e.detail?.card;
|
||||||
|
const id = collabIdentityOf(card);
|
||||||
|
if (!id) return;
|
||||||
|
editorSession = {
|
||||||
|
bucket_uid: id.bucket_uid,
|
||||||
|
card_uid: id.card_uid,
|
||||||
|
cardId: card?.id,
|
||||||
|
hbTimer: null,
|
||||||
|
inputCleanup: null,
|
||||||
|
acquired: false,
|
||||||
|
takeover: false,
|
||||||
|
saveBlockers: null,
|
||||||
|
};
|
||||||
|
// If a foreign lock is already known from the last poll, take over
|
||||||
|
// immediately — no point letting the user type into a doomed editor.
|
||||||
|
const existing = collabLocks.get(id.card_uid);
|
||||||
|
const self = window._collabSelf || null;
|
||||||
|
if (existing && existing.holder_username && existing.holder_username !== self) {
|
||||||
|
enterEditorTakeover(existing.holder_username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armFirstKeystrokeLock();
|
||||||
|
});
|
||||||
|
window.addEventListener('hyprcrd:editor-close', () => {
|
||||||
|
endEditorSession();
|
||||||
|
});
|
||||||
|
// Graceful release on tab close
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (editorSession?.acquired) {
|
||||||
|
try {
|
||||||
|
const url = 'api/collab-locks/' + encodeURIComponent(editorSession.bucket_uid) + '/' + encodeURIComponent(editorSession.card_uid);
|
||||||
|
fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Wait for #bucket-bar to exist (core renders it on startup)
|
||||||
|
if (!document.getElementById('bucket-bar')) {
|
||||||
|
setTimeout(init, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setupBarObserver();
|
||||||
|
setupClickInterceptor();
|
||||||
|
setupBoardObserver();
|
||||||
|
await loadCollabMeta();
|
||||||
|
await pollOnce();
|
||||||
|
await loadCollabMeta();
|
||||||
|
decorateBucketBar();
|
||||||
|
decorateBoardLocks();
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
hyprcrd.registerPlugin?.({
|
||||||
|
name: 'collab-buckets',
|
||||||
|
version: '1.1.0',
|
||||||
|
description: 'Collaborative buckets shared via the relay. First-class rows in the bucket bar with unread pulse/dot.',
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "collab-buckets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Collaborative buckets shared between relay tenants. Polls the relay for bucket updates and shows pulsing header tiles with unread indicators.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["collab-buckets.js"],
|
||||||
|
"css": ["collab-buckets.css"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<div id="email-body-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
<div id="email-body-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="ph ph-envelope-open text-3xl mb-2 block"></i>
|
<i class="ph ph-envelope-open text-3xl mb-2 block"></i>
|
||||||
select a message
|
<span class="cursor-blink">select a message</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="email-body" class="hidden flex flex-col flex-1 min-h-0">
|
<div id="email-body" class="hidden flex flex-col flex-1 min-h-0">
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ function gotoNextPage() {
|
|||||||
function renderMessageList() {
|
function renderMessageList() {
|
||||||
const list = $('email-list');
|
const list = $('email-list');
|
||||||
if (!currentMessages.length) {
|
if (!currentMessages.length) {
|
||||||
list.innerHTML = '<div class="p-4 text-xs text-t-dim italic">no messages</div>';
|
list.innerHTML = '<div class="p-4 text-xs text-t-dim italic cursor-blink">no messages</div>';
|
||||||
renderSelectionBar();
|
renderSelectionBar();
|
||||||
renderSpamToolbar();
|
renderSpamToolbar();
|
||||||
return;
|
return;
|
||||||
@@ -877,8 +877,42 @@ function init() {
|
|||||||
$('email-overlay').addEventListener('click', e => {
|
$('email-overlay').addEventListener('click', e => {
|
||||||
if (e.target === $('email-overlay')) closeEmail();
|
if (e.target === $('email-overlay')) closeEmail();
|
||||||
});
|
});
|
||||||
$('email-overlay').addEventListener('keydown', e => {
|
// Document-level Escape: works regardless of which element is focused
|
||||||
if (e.key === 'Escape') { closeEmail(); e.stopPropagation(); }
|
// (the previous overlay-level keydown only fired when focus was inside
|
||||||
|
// the overlay, which is rarely the case after open).
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (!$('email-overlay')?.classList.contains('open')) return;
|
||||||
|
// Don't steal Escape from a sub-modal that lives over the overlay
|
||||||
|
const dialog = document.getElementById('dialog');
|
||||||
|
if (dialog?.classList.contains('open')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeEmail();
|
||||||
|
});
|
||||||
|
// Document-level "m" toggles the mail overlay. Suppressed when the
|
||||||
|
// user is typing into an input / textarea / contenteditable so it
|
||||||
|
// doesn't intercept normal text entry. Also suppressed if any modal
|
||||||
|
// overlay is open (editor, search, settings, …) so we don't fight
|
||||||
|
// those for the hotkey.
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'm' && e.key !== 'M') return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const ae = document.activeElement;
|
||||||
|
if (ae) {
|
||||||
|
if (ae.isContentEditable) return;
|
||||||
|
const tag = ae.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
}
|
||||||
|
// Don't toggle while another full-screen overlay is in front
|
||||||
|
const blockers = ['modal', 'detail', 'search', 'settings-overlay', 'gallery', 'help', 'calendar', 'tasks', 'dialog'];
|
||||||
|
for (const id of blockers) {
|
||||||
|
if (document.getElementById(id)?.classList.contains('open')) return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const overlay = $('email-overlay');
|
||||||
|
if (overlay?.classList.contains('open')) closeEmail();
|
||||||
|
else openEmail();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('email-account-select').addEventListener('change', e => {
|
$('email-account-select').addEventListener('change', e => {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<div id="gitea-detail-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
<div id="gitea-detail-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<i class="ph ph-list-bullets text-3xl mb-2 block"></i>
|
<i class="ph ph-list-bullets text-3xl mb-2 block"></i>
|
||||||
select an issue
|
<span class="cursor-blink">select an issue</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ── Create issue form ── -->
|
<!-- ── Create issue form ── -->
|
||||||
|
|||||||
@@ -1593,6 +1593,35 @@ if (document.readyState === 'loading') {
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the gitea overlay, switch to the create-issue form and pre-fill
|
||||||
|
* title + body from a hyprcrd card. Does NOT submit — the user reviews
|
||||||
|
* and clicks Submit themselves. Called from the card right-click menu's
|
||||||
|
* "Make ticket" action (see public/js/modules/context-menu.js).
|
||||||
|
*/
|
||||||
|
window._giteaMakeIssueFromCard = async function (card) {
|
||||||
|
if (!card) return;
|
||||||
|
// Body might not be loaded in state.cards (lazy field) — fetch it.
|
||||||
|
let body = card.body;
|
||||||
|
if (body == null) {
|
||||||
|
try {
|
||||||
|
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||||
|
const fresh = all.find(c => c.id == card.id);
|
||||||
|
body = fresh?.body || '';
|
||||||
|
} catch { body = ''; }
|
||||||
|
}
|
||||||
|
await openGitea();
|
||||||
|
if (accounts.length === 0) return; // welcome view — user must configure first
|
||||||
|
await openCreateForm();
|
||||||
|
const titleInput = $('gitea-create-title');
|
||||||
|
const bodyInput = $('gitea-create-body');
|
||||||
|
if (titleInput) titleInput.value = card.title || '';
|
||||||
|
if (bodyInput) bodyInput.value = body || '';
|
||||||
|
// Cursor on the repo field so the user can pick the destination
|
||||||
|
// first. Title + body are already filled.
|
||||||
|
$('gitea-create-repo')?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
hyprcrd.registerPlugin({
|
hyprcrd.registerPlugin({
|
||||||
name: 'gitea',
|
name: 'gitea',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ async function refreshGalleryPicker() {
|
|||||||
if (!images.length) {
|
if (!images.length) {
|
||||||
gp.grid.innerHTML = `<div class="col-span-full text-center py-16" style="grid-column:1/-1;">
|
gp.grid.innerHTML = `<div class="col-span-full text-center py-16" style="grid-column:1/-1;">
|
||||||
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
||||||
<p class="font-pixel text-[10px] text-t-dim">no images yet — upload above</p>
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no images yet — upload above</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -705,7 +705,7 @@ function renderPickerList(query) {
|
|||||||
picker.list.innerHTML = filtered.map(c => {
|
picker.list.innerHTML = filtered.map(c => {
|
||||||
const col = c.color ? `background:${c.color};` : '';
|
const col = c.color ? `background:${c.color};` : '';
|
||||||
return `<button class="slideshow-pick-row" data-pick-target="card:${escHtml(c.id)}"><span style="display:inline-block;width:8px;height:8px;${col}"></span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(c.title || '(untitled)')}</span></button>`;
|
return `<button class="slideshow-pick-row" data-pick-target="card:${escHtml(c.id)}"><span style="display:inline-block;width:8px;height:8px;${col}"></span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(c.title || '(untitled)')}</span></button>`;
|
||||||
}).join('') || `<div class="font-pixel text-[10px] text-t-dim px-2 py-2">no matches</div>`;
|
}).join('') || `<div class="font-pixel text-[10px] text-t-dim px-2 py-2 cursor-blink">no matches</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
picker.search?.addEventListener('input', () => renderPickerList(picker.search.value));
|
picker.search?.addEventListener('input', () => renderPickerList(picker.search.value));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, csrf } from './runner.js';
|
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('API: Edge Cases', () => {
|
describe('API: Edge Cases', () => {
|
||||||
it('create card with special characters in title', async () => {
|
it('create card with special characters in title', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, csrf } from './runner.js';
|
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('API: Cards CRUD', () => {
|
describe('API: Cards CRUD', () => {
|
||||||
it('GET /api/cards returns array', async () => {
|
it('GET /api/cards returns array', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
|
async function makeCard(title, body) {
|
||||||
|
return fetch('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCard(id) {
|
||||||
|
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
|
||||||
|
return all.find(c => c.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearchReplace() {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Search: Find/Replace', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+H opens search with replace row visible', async () => {
|
||||||
|
openSearchReplace();
|
||||||
|
await sleep(200);
|
||||||
|
const overlay = document.getElementById('search');
|
||||||
|
expect(overlay?.classList.contains('open')).toBe(true);
|
||||||
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
|
expect(document.getElementById('search-replace-input')).toExist();
|
||||||
|
expect(document.getElementById('search-replace-all')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace toggle shows/hides replace row', async () => {
|
||||||
|
openSearchReplace();
|
||||||
|
await sleep(200);
|
||||||
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
|
click('#search-replace-toggle');
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('search-replace-row')?.classList.contains('hidden')).toBe(true);
|
||||||
|
click('#search-replace-toggle');
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace all updates card bodies after confirmation', async () => {
|
||||||
|
const c1 = await makeCard('__bfr_repl_a__', 'hello world hello');
|
||||||
|
const c2 = await makeCard('__bfr_repl_b__', 'hello there');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
openSearchReplace();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const replInput = document.getElementById('search-replace-input');
|
||||||
|
if (!searchInput || !replInput) return;
|
||||||
|
|
||||||
|
searchInput.value = 'hello';
|
||||||
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
replInput.value = 'goodbye';
|
||||||
|
replInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
const applyBtn = document.getElementById('search-replace-all');
|
||||||
|
expect(applyBtn.disabled).toBe(false);
|
||||||
|
click(applyBtn);
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const dialog = document.getElementById('dialog');
|
||||||
|
if (dialog?.classList.contains('open')) {
|
||||||
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
|
if (okBtn) click(okBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll until the replace has actually persisted
|
||||||
|
let updated1, updated2;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100);
|
||||||
|
updated1 = await getCard(c1.id);
|
||||||
|
if (updated1?.body === 'goodbye world goodbye') break;
|
||||||
|
}
|
||||||
|
updated2 = await getCard(c2.id);
|
||||||
|
expect(updated1.body).toBe('goodbye world goodbye');
|
||||||
|
expect(updated2.body).toBe('goodbye there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace all button disabled without replace text', async () => {
|
||||||
|
await makeCard('__bfr_dis__', 'some text');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
openSearchReplace();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (!searchInput) return;
|
||||||
|
searchInput.value = 'some';
|
||||||
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
const applyBtn = document.getElementById('search-replace-all');
|
||||||
|
expect(applyBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Board Rendering', () => {
|
describe('Board Rendering', () => {
|
||||||
it('renders card elements', () => {
|
it('renders card elements', () => {
|
||||||
@@ -124,3 +124,63 @@ describe('Board: Card Create & Delete', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Board: Focus restoration after overlay close', () => {
|
||||||
|
afterEach(() => closeAll());
|
||||||
|
|
||||||
|
it('closing the editor returns focus to body', async () => {
|
||||||
|
click('#add-btn');
|
||||||
|
await sleep(200);
|
||||||
|
// Editor is open — focus should be inside the modal (title input)
|
||||||
|
expect(document.activeElement !== document.body).toBe(true);
|
||||||
|
click('#modal-cancel');
|
||||||
|
await sleep(200);
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the detail view returns focus to body', async () => {
|
||||||
|
const card = $('#board [data-id]');
|
||||||
|
if (!card) return;
|
||||||
|
click(card);
|
||||||
|
await sleep(300);
|
||||||
|
if (!$('#detail')?.classList.contains('open')) return;
|
||||||
|
click('#detail-close');
|
||||||
|
await sleep(200);
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing search returns focus to body', async () => {
|
||||||
|
click('#search-btn');
|
||||||
|
await sleep(200);
|
||||||
|
if (!$('#search')?.classList.contains('open')) return;
|
||||||
|
expect(document.activeElement?.id).toBe('search-input');
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await sleep(200);
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arrow keys work after editor round-trip (enter → enter → escape)', async () => {
|
||||||
|
const card = $('#board [data-id]');
|
||||||
|
if (!card) return;
|
||||||
|
// Focus a card
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
if (!document.querySelector('#board .card-focused')) return;
|
||||||
|
// Enter → detail
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await sleep(300);
|
||||||
|
if (!$('#detail')?.classList.contains('open')) return;
|
||||||
|
// Enter → close detail, open editor
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await sleep(300);
|
||||||
|
if (!$('#modal')?.classList.contains('open')) return;
|
||||||
|
// Escape → close editor
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await sleep(200);
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
// Arrows should work again
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
expect(document.querySelector('#board .card-focused')).toExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Bucket Context Menu', () => {
|
describe('Bucket Context Menu', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -100,7 +100,11 @@ describe('Bucket Context Menu', () => {
|
|||||||
it('move-left disabled on first bucket', async () => {
|
it('move-left disabled on first bucket', async () => {
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
const tabs = $$('#bucket-bar [data-bucket]').filter(t => !['all', 'current', 'favorites', 'journal', 'none'].includes(t.dataset.bucket));
|
// data-bucket-drag is present on BOTH regular and super-bucket
|
||||||
|
// tiles but not on the fixed filters (all/current/favorites/…).
|
||||||
|
// Using it instead of [data-bucket] avoids missing super-bucket
|
||||||
|
// tiles which carry data-bucket-super instead.
|
||||||
|
const tabs = $$('#bucket-bar [data-bucket-drag]');
|
||||||
if (!tabs.length) return;
|
if (!tabs.length) return;
|
||||||
tabs[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
tabs[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Bucket Bar', () => {
|
describe('Bucket Bar', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Calendar: Day Click', () => {
|
describe('Calendar: Day Click', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Calendar', () => {
|
describe('Calendar', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Card: Tag Operations', () => {
|
describe('Card: Tag Operations', () => {
|
||||||
@@ -59,10 +59,15 @@ describe('Card: Due Date & Done', () => {
|
|||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({ due_date: '2099-12-31' }),
|
body: JSON.stringify({ due_date: '2099-12-31' }),
|
||||||
});
|
});
|
||||||
|
state.activeBucket = 'all';
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
// Poll for the card to appear in the DOM instead of a fixed sleep
|
||||||
|
let cardEl = null;
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
for (let i = 0; i < 40; i++) {
|
||||||
|
cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
|
if (cardEl) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
const pill = cardEl?.querySelector('.due-pill');
|
const pill = cardEl?.querySelector('.due-pill');
|
||||||
expect(pill).toExist();
|
expect(pill).toExist();
|
||||||
expect(pill.textContent).toContain('2099-12-31');
|
expect(pill.textContent).toContain('2099-12-31');
|
||||||
@@ -70,6 +75,7 @@ describe('Card: Due Date & Done', () => {
|
|||||||
|
|
||||||
it('card with #done tag shows done badge', async () => {
|
it('card with #done tag shows done badge', async () => {
|
||||||
if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return;
|
if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return;
|
||||||
|
if (state.hideDone || state.hideTagged) return;
|
||||||
const res = await fetch('api/cards', {
|
const res = await fetch('api/cards', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({ title: '__test_done_badge__', body: '#done', color: '#38bdf8' }),
|
body: JSON.stringify({ title: '__test_done_badge__', body: '#done', color: '#38bdf8' }),
|
||||||
@@ -91,12 +97,15 @@ describe('Card: Due Date & Done', () => {
|
|||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({ due_date: '2020-01-01' }),
|
body: JSON.stringify({ due_date: '2020-01-01' }),
|
||||||
});
|
});
|
||||||
|
state.activeBucket = 'all';
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
let cardEl = null;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
|
if (cardEl) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
expect(cardEl).toExist();
|
expect(cardEl).toExist();
|
||||||
// Overdue cards get danger border via inline style
|
|
||||||
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
|
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -110,7 +119,7 @@ describe('Card: Context Menu Actions', () => {
|
|||||||
rightClick(card);
|
rightClick(card);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
const favBtn = $$('#ctx-items [data-action="favorite"]')[0];
|
const favBtn = $$('#ctx-items [data-action="favorite"]')[0];
|
||||||
const wasFav = favBtn?.textContent.includes('unfavorite');
|
const wasFav = favBtn?.textContent.toLowerCase().includes('unfavorite');
|
||||||
click(favBtn);
|
click(favBtn);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|
||||||
@@ -118,13 +127,53 @@ describe('Card: Context Menu Actions', () => {
|
|||||||
rightClick(card);
|
rightClick(card);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
const favBtn2 = $$('#ctx-items [data-action="favorite"]')[0];
|
const favBtn2 = $$('#ctx-items [data-action="favorite"]')[0];
|
||||||
const isFav = favBtn2?.textContent.includes('unfavorite');
|
const isFav = favBtn2?.textContent.toLowerCase().includes('unfavorite');
|
||||||
expect(isFav).toBe(!wasFav);
|
expect(isFav).toBe(!wasFav);
|
||||||
// Toggle back
|
// Toggle back
|
||||||
click(favBtn2);
|
click(favBtn2);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('mark done toggles #done on cards without due_date', async () => {
|
||||||
|
// After clicking, the card has #done — if either filter is on it would
|
||||||
|
// disappear from the board before the second right-click can find it.
|
||||||
|
if (state.hideDone || state.hideTagged) return;
|
||||||
|
// Create a card with no due_date — Mark done used to be hidden in this case
|
||||||
|
const res = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_mark_done__', body: 'plain text', color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
|
if (!cardEl) return;
|
||||||
|
rightClick(cardEl);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
// The action must always be present, even with no due_date
|
||||||
|
const doneBtn = $$('#ctx-items [data-action="due-done"]')[0];
|
||||||
|
expect(doneBtn).toExist();
|
||||||
|
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
||||||
|
|
||||||
|
click(doneBtn);
|
||||||
|
await sleep(300);
|
||||||
|
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||||
|
expect((updated[0].body || '').toLowerCase()).toContain('#done');
|
||||||
|
expect(updated[0].tags || '').toContain('done');
|
||||||
|
|
||||||
|
// Re-open menu — the entry should now flip to "Unmark done"
|
||||||
|
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
||||||
|
await sleep(150);
|
||||||
|
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
||||||
|
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
||||||
|
|
||||||
|
click(doneBtn2);
|
||||||
|
await sleep(300);
|
||||||
|
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||||
|
expect((updated[0].body || '').toLowerCase().includes('#done')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('color submenu changes card color', async () => {
|
it('color submenu changes card color', async () => {
|
||||||
const res = await fetch('api/cards', {
|
const res = await fetch('api/cards', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
@@ -218,13 +267,136 @@ describe('Card: Grouping', () => {
|
|||||||
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
state.activeBucket = 'all';
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
let parentEl = null;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
// Child should not be a standalone card on board
|
parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||||
const childEl = document.querySelector(`[data-id="${child.id}"]`);
|
if (parentEl) break;
|
||||||
// It may or may not render depending on group view, but parent should exist
|
await sleep(50);
|
||||||
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
}
|
||||||
expect(parentEl).toExist();
|
expect(parentEl).toExist();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Card: Group Delete', () => {
|
||||||
|
afterEach(() => closeAll());
|
||||||
|
|
||||||
|
it('deleting a group removes parent AND all children', async () => {
|
||||||
|
// Disable confirm-delete so the non-group path doesn't block,
|
||||||
|
// but the group delete ALWAYS confirms regardless of this setting.
|
||||||
|
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||||
|
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||||
|
|
||||||
|
const parent = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_grpdel_parent__', body: '', color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
const child1 = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_grpdel_child1__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
const child2 = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_grpdel_child2__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// The group parent should be on the board
|
||||||
|
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||||
|
if (!parentEl) {
|
||||||
|
// Restore and skip if parent not visible (e.g. filter state)
|
||||||
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||||
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click → delete. The group delete dialog always shows
|
||||||
|
// regardless of the confirm-delete setting. We need to auto-
|
||||||
|
// confirm it. The showDialog function exposes the dialog element
|
||||||
|
// as #dialog; the OK button is .dialog-ok.
|
||||||
|
rightClick(parentEl);
|
||||||
|
await sleep(150);
|
||||||
|
click($$('#ctx-items [data-action="delete"]')[0]);
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// The confirmation dialog should be showing the child list
|
||||||
|
const dialog = document.getElementById('dialog');
|
||||||
|
if (dialog?.classList.contains('open')) {
|
||||||
|
const msg = dialog.querySelector('#dialog-msg');
|
||||||
|
// Dialog should mention both children
|
||||||
|
expect(msg.textContent).toContain('__test_grpdel_child1__');
|
||||||
|
expect(msg.textContent).toContain('__test_grpdel_child2__');
|
||||||
|
// Confirm the delete
|
||||||
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
|
if (okBtn) click(okBtn);
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All three cards should be gone
|
||||||
|
const remaining = await fetch('api/cards').then(r => r.json());
|
||||||
|
expect(remaining.some(c => c.id === parent.id)).toBe(false);
|
||||||
|
expect(remaining.some(c => c.id === child1.id)).toBe(false);
|
||||||
|
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
||||||
|
|
||||||
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||||
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Z after group delete restores parent + children', async () => {
|
||||||
|
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||||
|
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||||
|
|
||||||
|
const parent = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_grpundo_parent__', body: 'parent body', color: '#fb7185' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
const child = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_grpundo_child__', body: 'child body', color: '#38bdf8', parent_id: parent.id }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||||
|
if (!parentEl) {
|
||||||
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||||
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the group
|
||||||
|
rightClick(parentEl);
|
||||||
|
await sleep(150);
|
||||||
|
click($$('#ctx-items [data-action="delete"]')[0]);
|
||||||
|
await sleep(200);
|
||||||
|
const dialog = document.getElementById('dialog');
|
||||||
|
if (dialog?.classList.contains('open')) {
|
||||||
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
|
if (okBtn) click(okBtn);
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm both are gone
|
||||||
|
let cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
expect(cards.some(c => c.id === parent.id)).toBe(false);
|
||||||
|
expect(cards.some(c => c.id === child.id)).toBe(false);
|
||||||
|
|
||||||
|
// Undo
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||||
|
await sleep(600);
|
||||||
|
|
||||||
|
// Parent + child should be back (new ids, find by title)
|
||||||
|
cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
const restoredParent = cards.find(c => c.title === '__test_grpundo_parent__');
|
||||||
|
const restoredChild = cards.find(c => c.title === '__test_grpundo_child__');
|
||||||
|
expect(restoredParent).toExist();
|
||||||
|
expect(restoredChild).toExist();
|
||||||
|
// Child should point at the restored parent
|
||||||
|
expect(restoredChild.parent_id).toBe(restoredParent.id);
|
||||||
|
|
||||||
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||||
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
|
async function makeCard(title, body = '') {
|
||||||
|
return fetch('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCard(id) {
|
||||||
|
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
|
||||||
|
return all.find(c => c.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIds(ids, { bulk = false } = {}) {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = bulk;
|
||||||
|
for (const id of ids) state.selectedIds.add(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickJoin(rightClickTargetEl, winnerId) {
|
||||||
|
rightClick(rightClickTargetEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
if (!joinBtn) return false;
|
||||||
|
click(joinBtn);
|
||||||
|
// joinSelectedCards does: fetch full bodies → PATCH winner → N × DELETE
|
||||||
|
// losers → _reloadAll. Poll for the winner to have merged body.
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await sleep(100);
|
||||||
|
if (winnerId) {
|
||||||
|
const c = await getCard(winnerId);
|
||||||
|
if (c?.body?.includes('## ')) return true;
|
||||||
|
} else {
|
||||||
|
await sleep(600);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Card: Join — context menu visibility', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Join action when ≥2 cards are selected and right-clicked card is one of them', async () => {
|
||||||
|
const a = await makeCard('__join_vis_a__');
|
||||||
|
const b = await makeCard('__join_vis_b__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id]);
|
||||||
|
|
||||||
|
rightClick(aEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toExist();
|
||||||
|
expect(joinBtn.textContent.toLowerCase()).toContain('join');
|
||||||
|
expect(joinBtn.textContent).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show Join with only one card selected', async () => {
|
||||||
|
const a = await makeCard('__join_vis_solo__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id]);
|
||||||
|
|
||||||
|
rightClick(aEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT show Join when right-clicked card is not part of the selection', async () => {
|
||||||
|
const a = await makeCard('__join_vis_outside_a__');
|
||||||
|
const b = await makeCard('__join_vis_outside_b__');
|
||||||
|
const c = await makeCard('__join_vis_outside_c__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const cEl = document.querySelector(`[data-id="${c.id}"]`);
|
||||||
|
if (!cEl) return;
|
||||||
|
selectIds([a.id, b.id]);
|
||||||
|
|
||||||
|
rightClick(cEl);
|
||||||
|
await sleep(150);
|
||||||
|
const joinBtn = $$('#ctx-items [data-action="join"]')[0];
|
||||||
|
expect(joinBtn).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — sequential selection (first wins)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first ctrl-clicked card becomes the winner; others are deleted', async () => {
|
||||||
|
const first = await makeCard('__join_seq_first__', 'body of first');
|
||||||
|
const second = await makeCard('__join_seq_second__', 'body of second');
|
||||||
|
const third = await makeCard('__join_seq_third__', 'body of third');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const firstEl = document.querySelector(`[data-id="${first.id}"]`);
|
||||||
|
if (!firstEl) return;
|
||||||
|
selectIds([first.id, second.id, third.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(firstEl, first.id);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// Winner survives
|
||||||
|
const survivor = await getCard(first.id);
|
||||||
|
expect(survivor).toExist();
|
||||||
|
expect(survivor.title).toBe('__join_seq_first__');
|
||||||
|
// Losers deleted
|
||||||
|
expect(await getCard(second.id)).toBe(undefined);
|
||||||
|
expect(await getCard(third.id)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merged body lists every original title at the top', async () => {
|
||||||
|
const a = await makeCard('__join_titles_a__', 'aaa');
|
||||||
|
const b = await makeCard('__join_titles_b__', 'bbb');
|
||||||
|
const c = await makeCard('__join_titles_c__', 'ccc');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id, c.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(aEl, a.id);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const survivor = await getCard(a.id);
|
||||||
|
expect(survivor.body).toContain('__join_titles_a__');
|
||||||
|
expect(survivor.body).toContain('__join_titles_b__');
|
||||||
|
expect(survivor.body).toContain('__join_titles_c__');
|
||||||
|
// All three bodies preserved too
|
||||||
|
expect(survivor.body).toContain('aaa');
|
||||||
|
expect(survivor.body).toContain('bbb');
|
||||||
|
expect(survivor.body).toContain('ccc');
|
||||||
|
// Title list comes before the body content
|
||||||
|
const titlePos = survivor.body.indexOf('__join_titles_b__');
|
||||||
|
const bodyPos = survivor.body.indexOf('bbb');
|
||||||
|
expect(titlePos).toBeLessThan(bodyPos);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selection is cleared after a successful join', async () => {
|
||||||
|
const a = await makeCard('__join_clearsel_a__');
|
||||||
|
const b = await makeCard('__join_clearsel_b__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const aEl = document.querySelector(`[data-id="${a.id}"]`);
|
||||||
|
if (!aEl) return;
|
||||||
|
selectIds([a.id, b.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(aEl, a.id);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
expect(state.selectedIds.size).toBe(0);
|
||||||
|
expect(state.selectionWasBulk).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — bulk selection (longest title wins)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('longest-title card becomes the winner regardless of insertion order', async () => {
|
||||||
|
const short = await makeCard('__jbulk_x__', 'x body');
|
||||||
|
const longer = await makeCard('__jbulk_loooooong_winner_title__', 'l body');
|
||||||
|
const medium = await makeCard('__jbulk_med_xx__', 'm body');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const shortEl = document.querySelector(`[data-id="${short.id}"]`);
|
||||||
|
if (!shortEl) return;
|
||||||
|
// Insert in order short, longer, medium — but mark as bulk so the
|
||||||
|
// join code uses longest-title strategy.
|
||||||
|
selectIds([short.id, longer.id, medium.id], { bulk: true });
|
||||||
|
|
||||||
|
const ok = await clickJoin(shortEl, longer.id);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// The longest-titled card should be the survivor
|
||||||
|
const survivor = await getCard(longer.id);
|
||||||
|
expect(survivor).toExist();
|
||||||
|
expect(survivor.title).toBe('__jbulk_loooooong_winner_title__');
|
||||||
|
// The other two are deleted
|
||||||
|
expect(await getCard(short.id)).toBe(undefined);
|
||||||
|
expect(await getCard(medium.id)).toBe(undefined);
|
||||||
|
// Survivor body still has all titles
|
||||||
|
expect(survivor.body).toContain('__jbulk_x__');
|
||||||
|
expect(survivor.body).toContain('__jbulk_med_xx__');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Join — undo', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Z reverts a join: winner body restored, losers recreated', async () => {
|
||||||
|
const winner = await makeCard('__join_undo_winner__', 'original winner body');
|
||||||
|
const loser = await makeCard('__join_undo_loser__', 'original loser body');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const winnerEl = document.querySelector(`[data-id="${winner.id}"]`);
|
||||||
|
if (!winnerEl) return;
|
||||||
|
selectIds([winner.id, loser.id], { bulk: false });
|
||||||
|
|
||||||
|
const ok = await clickJoin(winnerEl, winner.id);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// Sanity: loser is gone, winner has merged body
|
||||||
|
expect(await getCard(loser.id)).toBe(undefined);
|
||||||
|
let survivor = await getCard(winner.id);
|
||||||
|
expect(survivor.body).toContain('__join_undo_loser__');
|
||||||
|
|
||||||
|
// Undo via global Ctrl+Z
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
// Winner body restored to original
|
||||||
|
survivor = await getCard(winner.id);
|
||||||
|
expect(survivor.body).toBe('original winner body');
|
||||||
|
|
||||||
|
// A loser card was recreated with the same title + body
|
||||||
|
// (its id will differ — find by title since the original is gone)
|
||||||
|
const all = await fetch('api/cards').then(r => r.json());
|
||||||
|
const restored = all.find(c => c.title === '__join_undo_loser__');
|
||||||
|
expect(restored).toExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, rightClick, sleep } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Context Menu', () => {
|
describe('Context Menu', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -52,4 +52,170 @@ describe('Context Menu', () => {
|
|||||||
await sleep(50);
|
await sleep(50);
|
||||||
expect($('#ctx-menu').classList.contains('hidden')).toBe(true);
|
expect($('#ctx-menu').classList.contains('hidden')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('has copy-text action', async () => {
|
||||||
|
const card = $('#board [data-id]');
|
||||||
|
if (!card) return;
|
||||||
|
rightClick(card);
|
||||||
|
await sleep(150);
|
||||||
|
const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action);
|
||||||
|
expect(actions).toContain('copy-text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy-text strips tags, code blocks and image markdown', async () => {
|
||||||
|
// Stub navigator.clipboard.writeText so we can capture what would be copied
|
||||||
|
// without depending on a focused/secure-context clipboard.
|
||||||
|
const origWrite = navigator.clipboard?.writeText?.bind(navigator.clipboard);
|
||||||
|
let captured = null;
|
||||||
|
try {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: async (t) => { captured = t; } },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// some browsers don't allow re-defining `clipboard` — bail out cleanly
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = [
|
||||||
|
'first line',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'```js',
|
||||||
|
'console.log("nope")',
|
||||||
|
'```',
|
||||||
|
'second line #tagone with #tagtwo inline',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const res = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title: '__test_copy_text__', body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
|
if (!cardEl) return;
|
||||||
|
rightClick(cardEl);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const copyBtn = $$('#ctx-items [data-action="copy-text"]')[0];
|
||||||
|
expect(copyBtn).toExist();
|
||||||
|
click(copyBtn);
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
expect(captured).toExist();
|
||||||
|
// Title goes first, separated by a blank line
|
||||||
|
expect(captured.startsWith('__test_copy_text__\n\n')).toBe(true);
|
||||||
|
expect(captured).toContain('first line');
|
||||||
|
expect(captured).toContain('second line');
|
||||||
|
// Tags removed (chip + suffix), but the surrounding word "with" stays
|
||||||
|
expect(captured).toContain('with');
|
||||||
|
expect(captured.includes('#tagone')).toBe(false);
|
||||||
|
expect(captured.includes('#tagtwo')).toBe(false);
|
||||||
|
// Fenced code block dropped entirely
|
||||||
|
expect(captured.includes('console.log')).toBe(false);
|
||||||
|
expect(captured.includes('```')).toBe(false);
|
||||||
|
// Image markdown dropped
|
||||||
|
expect(captured.includes('uploads/foo.png')).toBe(false);
|
||||||
|
expect(captured.includes('![')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText: origWrite || (async () => {}) },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Card: Make ticket (gitea)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
// Always close the gitea overlay so subsequent tests aren't
|
||||||
|
// running on top of it
|
||||||
|
document.getElementById('gitea-overlay')?.classList.remove('open');
|
||||||
|
await sleep(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('action is exposed when the gitea plugin is loaded', () => {
|
||||||
|
// The make-ticket entry only renders when the plugin exposes
|
||||||
|
// _giteaMakeIssueFromCard. If the plugin isn't loaded in this
|
||||||
|
// build, treat as a soft skip — nothing to test.
|
||||||
|
if (typeof window._giteaMakeIssueFromCard !== 'function') return;
|
||||||
|
const card = $('#board [data-id]');
|
||||||
|
if (!card) return;
|
||||||
|
rightClick(card);
|
||||||
|
// Use sleep here, the menu render is synchronous after the dispatch
|
||||||
|
const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action);
|
||||||
|
expect(actions).toContain('make-ticket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking make-ticket pre-fills the gitea create form (no submit)', async () => {
|
||||||
|
if (typeof window._giteaMakeIssueFromCard !== 'function') return;
|
||||||
|
|
||||||
|
const title = '__test_make_ticket_title__';
|
||||||
|
const body = 'a body that should land in the gitea form';
|
||||||
|
const res = await fetch('api/cards', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
|
if (!cardEl) return;
|
||||||
|
rightClick(cardEl);
|
||||||
|
await sleep(150);
|
||||||
|
const btn = $$('#ctx-items [data-action="make-ticket"]')[0];
|
||||||
|
expect(btn).toExist();
|
||||||
|
click(btn);
|
||||||
|
|
||||||
|
// openGitea + openCreateForm involve account fetch + repo fetch.
|
||||||
|
// Wait until the gitea overlay is open OR a reasonable timeout
|
||||||
|
// passes. Without a configured gitea account the welcome view
|
||||||
|
// shows instead — that's a soft skip since we can't test the
|
||||||
|
// pre-fill in that case.
|
||||||
|
let overlay = null;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
overlay = document.getElementById('gitea-overlay');
|
||||||
|
if (overlay?.classList.contains('open')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
if (!overlay?.classList.contains('open')) return;
|
||||||
|
|
||||||
|
const welcome = document.getElementById('gitea-view-welcome');
|
||||||
|
if (welcome && !welcome.classList.contains('hidden')) {
|
||||||
|
// No gitea accounts configured — function returns early,
|
||||||
|
// form never renders, can't test the pre-fill path.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until openCreateForm has finished its async repo load
|
||||||
|
// and surfaced the form (it's hidden by default).
|
||||||
|
let form = null;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
form = document.getElementById('gitea-create-form');
|
||||||
|
if (form && !form.classList.contains('hidden')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
expect(form && !form.classList.contains('hidden')).toBe(true);
|
||||||
|
|
||||||
|
// The form must NOT auto-submit — the title and body should be
|
||||||
|
// populated and the form still visible afterwards. The pre-fill
|
||||||
|
// happens after an async openCreateForm, so poll until the title
|
||||||
|
// input has our expected value or we time out.
|
||||||
|
const titleInput = document.getElementById('gitea-create-title');
|
||||||
|
const bodyInput = document.getElementById('gitea-create-body');
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
if (titleInput?.value === title) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
expect(titleInput?.value).toBe(title);
|
||||||
|
expect(bodyInput?.value).toBe(body);
|
||||||
|
// Issues list / detail view should NOT be the foreground
|
||||||
|
const detail = document.getElementById('gitea-detail');
|
||||||
|
expect(detail?.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
import { showDialog } from '../../js/modules/dialog.js';
|
import { showDialog } from '../../js/modules/dialog.js';
|
||||||
|
|
||||||
describe('Datepicker: Rendering', () => {
|
describe('Datepicker: Rendering', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Detail: Done Toggle', () => {
|
describe('Detail: Done Toggle', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Detail View', () => {
|
describe('Detail View', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
@@ -48,8 +48,12 @@ describe('Detail View', () => {
|
|||||||
if (!card) return;
|
if (!card) return;
|
||||||
click(card);
|
click(card);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
if (!$('#detail')?.classList.contains('open')) return;
|
||||||
pressKey('Escape');
|
pressKey('Escape');
|
||||||
await sleep(150);
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (!$('#detail')?.classList.contains('open')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
expect($('#detail')).toBeHidden();
|
expect($('#detail')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Editor: Color Picker', () => {
|
describe('Editor: Color Picker', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
@@ -68,7 +68,13 @@ describe('Editor: Save Card', () => {
|
|||||||
await sleep(50);
|
await sleep(50);
|
||||||
|
|
||||||
click('#modal-save-close');
|
click('#modal-save-close');
|
||||||
await sleep(400);
|
// doSave(true) does API PATCH + _reloadAll (2-3 network calls)
|
||||||
|
// before closeModal — poll until the modal is actually hidden
|
||||||
|
// instead of a fixed sleep that races against server speed.
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (!$('#modal')?.classList.contains('open')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
expect($('#modal')).toBeHidden();
|
expect($('#modal')).toBeHidden();
|
||||||
// Card should exist
|
// Card should exist
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, type, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Editor: Empty Title Prevention', () => {
|
describe('Editor: Empty Title Prevention', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
// Helper: set selection range in textarea
|
// Helper: set selection range in textarea
|
||||||
function selectRange(textarea, start, end) {
|
function selectRange(textarea, start, end) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Editor: Structure', () => {
|
describe('Editor: Structure', () => {
|
||||||
it('has all elements', () => {
|
it('has all elements', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, csrf } from './runner.js';
|
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Export: JSON ZIP', () => {
|
describe('Export: JSON ZIP', () => {
|
||||||
it('export endpoint returns ZIP', async () => {
|
it('export endpoint returns ZIP', async () => {
|
||||||
@@ -41,30 +41,26 @@ describe('Export: Markdown Vault', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Export: Backup Integrity', () => {
|
describe('Export: Backup Integrity', () => {
|
||||||
it('export→import round-trip preserves card and bucket count', async () => {
|
it('export→import round-trip removes extra cards (replace mode)', async () => {
|
||||||
// Snapshot current state
|
// Snapshot + export
|
||||||
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
||||||
const bucketsBefore = await fetch('api/buckets').then(r => r.json());
|
|
||||||
|
|
||||||
// Export current state
|
|
||||||
const blob = await fetch('api/export').then(r => r.blob());
|
const blob = await fetch('api/export').then(r => r.blob());
|
||||||
|
|
||||||
// Create some extra cards that should be wiped on replace-import
|
// Create an extra card that should be wiped on replace-import
|
||||||
await fetch('api/cards', {
|
await fetch('api/cards', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({ title: '__extra_should_vanish__', body: '', color: '#38bdf8' }),
|
body: JSON.stringify({ title: '__extra_should_vanish__', body: '', color: '#38bdf8' }),
|
||||||
});
|
});
|
||||||
|
const withExtra = await fetch('api/cards').then(r => r.json());
|
||||||
|
expect(withExtra.length).toBe(cardsBefore.length + 1);
|
||||||
|
|
||||||
// Import (replace mode) — should restore exact state from export
|
// Import (replace mode)
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', blob, 'roundtrip.zip');
|
form.append('file', blob, 'roundtrip.zip');
|
||||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
const importRes = await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||||
|
if (!importRes.ok) return; // skip if import fails (e.g. missing zip extension)
|
||||||
|
|
||||||
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
||||||
const bucketsAfter = await fetch('api/buckets').then(r => r.json());
|
|
||||||
expect(cardsAfter.length).toBe(cardsBefore.length);
|
|
||||||
expect(bucketsAfter.length).toBe(bucketsBefore.length);
|
|
||||||
// Extra card should be gone
|
|
||||||
expect(cardsAfter.some(c => c.title === '__extra_should_vanish__')).toBe(false);
|
expect(cardsAfter.some(c => c.title === '__extra_should_vanish__')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,4 +110,28 @@ describe('Export: Backup Integrity', () => {
|
|||||||
expect(found.tags).toContain('beta');
|
expect(found.tags).toContain('beta');
|
||||||
expect(found.due_date).toBe('2099-12-31');
|
expect(found.due_date).toBe('2099-12-31');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('export→import preserves bucket color', async () => {
|
||||||
|
// Create a bucket with a recognizable name and patch a custom color onto it
|
||||||
|
const b = await fetch('api/buckets', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ name: '__test_bucket_color__' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
await fetch(`api/buckets/${b.id}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ color: '#fb7185' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-trip through export → replace-import
|
||||||
|
const blob = await fetch('api/export').then(r => r.blob());
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'roundtrip.zip');
|
||||||
|
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||||
|
|
||||||
|
// The bucket must reappear with the same custom color (not the schema default)
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
const found = buckets.find(x => x.name === '__test_bucket_color__');
|
||||||
|
expect(found).toExist();
|
||||||
|
expect(found.color).toBe('#fb7185');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Favorites Bucket', () => {
|
describe('Favorites Bucket', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
import { state, parseTags, sortedCards } from '../../js/modules/state.js';
|
import { state, parseTags, sortedCards } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Filtering: Tag AND Logic', () => {
|
describe('Filtering: Tag AND Logic', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Gallery', () => {
|
describe('Gallery', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
@@ -9,7 +9,10 @@ describe('Gallery', () => {
|
|||||||
|
|
||||||
it('opens on button click', async () => {
|
it('opens on button click', async () => {
|
||||||
click('#gallery-btn');
|
click('#gallery-btn');
|
||||||
await sleep(250);
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if ($('#gallery')?.classList.contains('open')) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
expect($('#gallery')).toBeVisible();
|
expect($('#gallery')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,13 +22,19 @@ describe('Gallery', () => {
|
|||||||
|
|
||||||
it('shows image count', async () => {
|
it('shows image count', async () => {
|
||||||
click('#gallery-btn');
|
click('#gallery-btn');
|
||||||
await sleep(250);
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if ($('#gallery')?.classList.contains('open')) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
expect($('#gallery-count')).toExist();
|
expect($('#gallery-count')).toExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes on close button', async () => {
|
it('closes on close button', async () => {
|
||||||
click('#gallery-btn');
|
click('#gallery-btn');
|
||||||
await sleep(200);
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if ($('#gallery')?.classList.contains('open')) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
click('#gallery-close');
|
click('#gallery-close');
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
expect($('#gallery')).toBeHidden();
|
expect($('#gallery')).toBeHidden();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, pressKey, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, pressKey, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Help', () => {
|
describe('Help', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Image Fullscreen Viewer', () => {
|
describe('Image Fullscreen Viewer', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
function enableLines() {
|
function enableLines() {
|
||||||
const gutter = $('#editor-gutter');
|
const gutter = $('#editor-gutter');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// The test runner is heavy (40+ test files), so we only load it when:
|
// The test runner is heavy (40+ test files), so we only load it when:
|
||||||
// - URL contains ?test query param → auto-run
|
// - URL contains ?test query param → auto-run
|
||||||
// - Ctrl+Shift+T pressed → manual open
|
// - Ctrl+Shift+Alt+T pressed → manual open
|
||||||
//
|
//
|
||||||
// Tests are completely optional — without this plugin the app has no
|
// Tests are completely optional — without this plugin the app has no
|
||||||
// test runner code loaded at all.
|
// test runner code loaded at all.
|
||||||
@@ -16,19 +16,83 @@ const V = '?v=' + Date.now();
|
|||||||
|
|
||||||
// Auto-load if ?test in query string
|
// Auto-load if ?test in query string
|
||||||
if (new URLSearchParams(location.search).has('test')) {
|
if (new URLSearchParams(location.search).has('test')) {
|
||||||
|
ensureProgressBar();
|
||||||
import('./tests.js' + V);
|
import('./tests.js' + V);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+Shift+T → open runner
|
// Keyboard shortcut: Ctrl+Shift+Alt+T → open runner.
|
||||||
|
//
|
||||||
|
// runner.js is imported with the SAME `?v=2` query string the test
|
||||||
|
// files use (see RUNNER_V below). Same URL → same module instance, so
|
||||||
|
// suite registration in test files and runAll in the loader run against
|
||||||
|
// the same `suites` array. Bump RUNNER_V whenever runner.js changes
|
||||||
|
// in a way that needs cache invalidation across users.
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.key === 'T' || e.key === 't')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
ensureProgressBar();
|
||||||
import('./tests.js' + V).then(() =>
|
import('./tests.js' + V).then(() =>
|
||||||
import('./runner.js' + V).then(m => m.open?.())
|
import('./runner.js' + RUNNER_V).then(m => m.open?.())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Must match the `?v=…` suffix used in test files' `from './runner.js?v=…'`
|
||||||
|
// imports — bump both in lock-step to invalidate the runner.js cache.
|
||||||
|
const RUNNER_V = '?v=4';
|
||||||
|
|
||||||
|
function ensureProgressBar() {
|
||||||
|
if (document.getElementById('test-progress')) return;
|
||||||
|
// Push the whole document down by 30px AND shrink the body by the
|
||||||
|
// same amount so the footer doesn't get clipped (body is h-screen +
|
||||||
|
// overflow-hidden, so without the height shrink the bottom 30px get
|
||||||
|
// pushed off-viewport). hideProgress() reverts both.
|
||||||
|
document.body.style.marginTop = '30px';
|
||||||
|
document.body.style.height = 'calc(100vh - 30px)';
|
||||||
|
// Transparent overlay that blocks all mouse interaction while tests
|
||||||
|
// run. Tests dispatch synthetic events so they still work; only real
|
||||||
|
// user clicks/hovers are absorbed. Removed by hideProgress().
|
||||||
|
let shield = document.getElementById('test-shield');
|
||||||
|
if (!shield) {
|
||||||
|
shield = document.createElement('div');
|
||||||
|
shield.id = 'test-shield';
|
||||||
|
const ss = shield.style;
|
||||||
|
ss.setProperty('position', 'fixed', 'important');
|
||||||
|
ss.setProperty('inset', '30px 0 0 0', 'important');
|
||||||
|
ss.setProperty('z-index', '2147483646', 'important');
|
||||||
|
ss.setProperty('background', 'transparent', 'important');
|
||||||
|
ss.setProperty('cursor', 'not-allowed', 'important');
|
||||||
|
document.documentElement.appendChild(shield);
|
||||||
|
}
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'test-progress';
|
||||||
|
const s = bar.style;
|
||||||
|
s.setProperty('position', 'fixed', 'important');
|
||||||
|
s.setProperty('top', '0', 'important');
|
||||||
|
s.setProperty('left', '0', 'important');
|
||||||
|
s.setProperty('right', '0', 'important');
|
||||||
|
s.setProperty('height', '30px', 'important');
|
||||||
|
s.setProperty('z-index', '2147483647', 'important');
|
||||||
|
s.setProperty('background', '#000', 'important');
|
||||||
|
s.setProperty('color', '#cbd5e1', 'important');
|
||||||
|
s.setProperty('font-family', 'monospace', 'important');
|
||||||
|
s.setProperty('font-size', '11px', 'important');
|
||||||
|
s.setProperty('display', 'flex', 'important');
|
||||||
|
s.setProperty('align-items', 'center', 'important');
|
||||||
|
s.setProperty('gap', '10px', 'important');
|
||||||
|
s.setProperty('padding', '0 12px', 'important');
|
||||||
|
s.setProperty('border-bottom', '1px solid #334155', 'important');
|
||||||
|
s.setProperty('pointer-events', 'none', 'important');
|
||||||
|
bar.innerHTML = `
|
||||||
|
<span id="test-progress-label" style="white-space:nowrap;color:#38bdf8;font-weight:bold;flex-shrink:0;">tests starting…</span>
|
||||||
|
<span id="test-progress-name" style="flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#94a3b8;"></span>
|
||||||
|
<div style="position:relative;flex:0 0 50%;height:10px;background:#1e293b;border:1px solid #334155;">
|
||||||
|
<div id="test-progress-fill" style="position:absolute;left:0;top:0;bottom:0;width:0%;background:#38bdf8;transition:width 0.15s linear;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.documentElement.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
hyprcrd?.registerPlugin?.({
|
hyprcrd?.registerPlugin?.({
|
||||||
name: 'tests',
|
name: 'tests',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
import { describe, it, expect } from './runner.js?v=4';
|
||||||
import { renderMd } from '../../js/modules/markdown.js';
|
import { renderMd } from '../../js/modules/markdown.js';
|
||||||
|
|
||||||
describe('Markdown: Blank Lines', () => {
|
describe('Markdown: Blank Lines', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
import { describe, it, expect } from './runner.js?v=4';
|
||||||
import { renderMd, formatTodoItems, stripMd } from '../../js/modules/markdown.js';
|
import { renderMd, formatTodoItems, stripMd } from '../../js/modules/markdown.js';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Multi-Select', () => {
|
describe('Multi-Select', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, $ } from './runner.js';
|
import { describe, it, expect, $ } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Notification Banner', () => {
|
describe('Notification Banner', () => {
|
||||||
it('banner element exists', () => {
|
it('banner element exists', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tests",
|
"name": "tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "UI + state test runner. Loads only when ?test query param is present or Ctrl+Shift+T is pressed.",
|
"description": "UI + state test runner. Loads only when ?test query param is present or Ctrl+Shift+Alt+T is pressed.",
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"js": ["loader.js"],
|
"js": ["loader.js"],
|
||||||
"css": [],
|
"css": [],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, type, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Quick Capture: Open/Close', () => {
|
describe('Quick Capture: Open/Close', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -46,7 +46,7 @@ describe('Quick Capture: Card Creation', () => {
|
|||||||
await closeAll();
|
await closeAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Enter creates card with #inbox tag', async () => {
|
it('Enter creates card in active bucket', async () => {
|
||||||
pressKey('n', { ctrlKey: true });
|
pressKey('n', { ctrlKey: true });
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const input = $('#quick-capture-input');
|
const input = $('#quick-capture-input');
|
||||||
@@ -57,7 +57,6 @@ describe('Quick Capture: Card Creation', () => {
|
|||||||
const cards = await fetch('api/cards').then(r => r.json());
|
const cards = await fetch('api/cards').then(r => r.json());
|
||||||
const found = cards.find(c => c.title === '__test_quick_capture__');
|
const found = cards.find(c => c.title === '__test_quick_capture__');
|
||||||
expect(found).toExist();
|
expect(found).toExist();
|
||||||
expect(found.tags).toContain('inbox');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('capture bar closes after Enter', async () => {
|
it('capture bar closes after Enter', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, csrf } from './runner.js';
|
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Relay: Push API', () => {
|
describe('Relay: Push API', () => {
|
||||||
it('push without config returns error', async () => {
|
it('push without config returns error', async () => {
|
||||||
@@ -15,6 +15,8 @@ describe('Relay: Push API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('push without card_id returns error', async () => {
|
it('push without card_id returns error', async () => {
|
||||||
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
|
if (!settings.url || !settings.key) return; // card_id check is after config check
|
||||||
const res = await fetch('api/relay/push', {
|
const res = await fetch('api/relay/push', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
import { describe, it, expect } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
||||||
|
|||||||
+165
-19
@@ -1,13 +1,19 @@
|
|||||||
// ── hyprcrd GUI Test Runner ──
|
// ── hyprcrd GUI Test Runner ──
|
||||||
// Self-contained in-app test framework. No external dependencies.
|
// Self-contained in-app test framework. No external dependencies.
|
||||||
// Activate: ?test=1 in URL or Ctrl+Shift+T
|
// Activate: ?test=1 in URL or Ctrl+Shift+Alt+T
|
||||||
// Panel is hidden during test run so interactions are visible.
|
// Panel is hidden during test run so interactions are visible.
|
||||||
// Backup is created before and auto-restored after every run.
|
// Backup is created before and auto-restored after every run.
|
||||||
|
|
||||||
import { getCsrf } from '../../js/modules/util.js';
|
import { getCsrf } from '../../js/modules/util.js';
|
||||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
||||||
|
|
||||||
const suites = [];
|
// Suites live on window so two runner.js instances (one cached and loaded
|
||||||
|
// by test files via bare imports, one fresh and loaded by loader.js with
|
||||||
|
// ?v=) can share registration. The cached runner provides describe/it/
|
||||||
|
// expect at module-load time; the fresh runner runs `runAll` against the
|
||||||
|
// shared array. Without this we'd see "0/0 passed" whenever the cached
|
||||||
|
// runner is older than the loader.
|
||||||
|
const suites = (window._testSuites = window._testSuites || []);
|
||||||
let currentSuite = null;
|
let currentSuite = null;
|
||||||
|
|
||||||
// ── Public API ──
|
// ── Public API ──
|
||||||
@@ -165,7 +171,7 @@ export function csrf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closeAll() {
|
export async function closeAll() {
|
||||||
for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks']) {
|
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||||
}
|
}
|
||||||
@@ -173,6 +179,8 @@ export async function closeAll() {
|
|||||||
document.querySelector('#select-bar')?.classList.add('hidden');
|
document.querySelector('#select-bar')?.classList.add('hidden');
|
||||||
document.querySelector('.bucket-picker')?.remove();
|
document.querySelector('.bucket-picker')?.remove();
|
||||||
document.querySelector('.orphan-lightbox')?.remove();
|
document.querySelector('.orphan-lightbox')?.remove();
|
||||||
|
document.getElementById('bucket-super-popover')?.remove();
|
||||||
|
document.getElementById('gitea-overlay')?.classList.remove('open');
|
||||||
// Clear multi-select
|
// Clear multi-select
|
||||||
document.querySelectorAll('.card-selected').forEach(el => {
|
document.querySelectorAll('.card-selected').forEach(el => {
|
||||||
el.classList.remove('card-selected');
|
el.classList.remove('card-selected');
|
||||||
@@ -200,11 +208,62 @@ async function restoreBackup() {
|
|||||||
|
|
||||||
// ── Runner ──
|
// ── Runner ──
|
||||||
|
|
||||||
async function runAll() {
|
// Fixed-top progress strip shown while tests are running. The main test
|
||||||
const results = [];
|
// panel is hidden during the run so the suite can interact with the UI;
|
||||||
|
// this strip gives the user a way to see how far along the run is.
|
||||||
|
//
|
||||||
|
// loader.js creates the DOM node when the user presses the shortcut so
|
||||||
|
// it appears immediately (loader.js is cache-busted per page load via
|
||||||
|
// the plugin manager's ?v=time()). The runner — which lives at a stable
|
||||||
|
// URL so suite registration is consistent — only updates the existing
|
||||||
|
// node. If runner.js is cached and stale, the placeholder still shows
|
||||||
|
// up so the user can tell something happened.
|
||||||
|
function getProgressBar() {
|
||||||
|
return document.getElementById('test-progress');
|
||||||
|
}
|
||||||
|
function updateProgress(current, total, name, failed) {
|
||||||
|
const bar = getProgressBar();
|
||||||
|
if (!bar) return;
|
||||||
|
const label = bar.querySelector('#test-progress-label');
|
||||||
|
const nameEl = bar.querySelector('#test-progress-name');
|
||||||
|
const fill = bar.querySelector('#test-progress-fill');
|
||||||
|
if (label) label.textContent = `test ${current}/${total}:`;
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
if (fill) {
|
||||||
|
fill.style.width = (current / total * 100) + '%';
|
||||||
|
if (failed) fill.style.background = '#fb7185';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideProgress() {
|
||||||
|
getProgressBar()?.remove();
|
||||||
|
document.getElementById('test-shield')?.remove();
|
||||||
|
document.body.style.marginTop = '';
|
||||||
|
document.body.style.height = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run tests. With `only=null` runs everything; with `only` set to a
|
||||||
|
* Set<string> of `${suite.name}::${test.name}` keys, runs just those.
|
||||||
|
* The progress bar denominator reflects the filtered count so the
|
||||||
|
* fill bar makes sense for both full runs and retries.
|
||||||
|
*/
|
||||||
|
async function runAll(only = null) {
|
||||||
|
const allTests = [];
|
||||||
for (const suite of suites) {
|
for (const suite of suites) {
|
||||||
const suiteResult = { name: suite.name, tests: [] };
|
|
||||||
for (const test of suite.tests) {
|
for (const test of suite.tests) {
|
||||||
|
if (!only || only.has(`${suite.name}::${test.name}`)) {
|
||||||
|
allTests.push({ suite, test });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = allTests.length;
|
||||||
|
updateProgress(0, total, only ? 'retrying…' : 'starting…', false);
|
||||||
|
let done = 0;
|
||||||
|
let anyFailed = false;
|
||||||
|
const suiteMap = new Map();
|
||||||
|
for (const { suite, test } of allTests) {
|
||||||
|
done++;
|
||||||
|
updateProgress(done, total, `${suite.name} — ${test.name}`, anyFailed);
|
||||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
@@ -214,20 +273,23 @@ async function runAll() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.status = 'fail';
|
entry.status = 'fail';
|
||||||
entry.error = err.message;
|
entry.error = err.message;
|
||||||
|
anyFailed = true;
|
||||||
// Always run afterEach on failure too
|
// Always run afterEach on failure too
|
||||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
||||||
}
|
}
|
||||||
entry.time = Math.round(performance.now() - t0);
|
entry.time = Math.round(performance.now() - t0);
|
||||||
suiteResult.tests.push(entry);
|
if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] });
|
||||||
|
suiteMap.get(suite.name).tests.push(entry);
|
||||||
}
|
}
|
||||||
results.push(suiteResult);
|
// Don't hide the progress bar here — run() does it after the retry
|
||||||
}
|
// pass (if any) so the bar stays visible during the whole sequence.
|
||||||
return results;
|
return [...suiteMap.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UI Panel ──
|
// ── UI Panel ──
|
||||||
|
|
||||||
let panel = null;
|
let panel = null;
|
||||||
|
let lastResults = null;
|
||||||
|
|
||||||
function createPanel() {
|
function createPanel() {
|
||||||
if (panel) { panel.remove(); panel = null; }
|
if (panel) { panel.remove(); panel = null; }
|
||||||
@@ -248,9 +310,35 @@ function createPanel() {
|
|||||||
document.body.appendChild(panel);
|
document.body.appendChild(panel);
|
||||||
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
||||||
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
||||||
|
// Copy: when failures exist, only copy the failed + flaky tests so
|
||||||
|
// the user can paste the relevant part without wading through 200+
|
||||||
|
// lines of green. When everything passed, copy the full output.
|
||||||
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
||||||
const output = panel.querySelector('#test-output');
|
let text;
|
||||||
const text = output.innerText;
|
if (lastResults) {
|
||||||
|
const hasFailures = lastResults.some(s => s.tests.some(t => t.status === 'fail' || t.retried));
|
||||||
|
if (hasFailures) {
|
||||||
|
const lines = [];
|
||||||
|
for (const s of lastResults) {
|
||||||
|
const relevant = s.tests.filter(t => t.status === 'fail' || t.retried);
|
||||||
|
if (!relevant.length) continue;
|
||||||
|
lines.push(s.name);
|
||||||
|
for (const t of relevant) {
|
||||||
|
const icon = t.status === 'fail' ? '✗' : '⚠';
|
||||||
|
const note = t.retried ? (t.status === 'pass' ? ' (flaky — passed on retry)' : ' (failed twice)') : '';
|
||||||
|
lines.push(`${icon} ${t.name} ${t.time}ms${note}`);
|
||||||
|
if (t.error) lines.push(` ${t.error}`);
|
||||||
|
if (t.originalError) lines.push(` first run: ${t.originalError}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
text = lines.join('\n').trim();
|
||||||
|
} else {
|
||||||
|
text = panel.querySelector('#test-output').innerText;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text = panel.querySelector('#test-output').innerText;
|
||||||
|
}
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
const btn = panel.querySelector('#test-copy-btn');
|
const btn = panel.querySelector('#test-copy-btn');
|
||||||
btn.textContent = 'copied';
|
btn.textContent = 'copied';
|
||||||
@@ -261,9 +349,10 @@ function createPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(results) {
|
function renderResults(results) {
|
||||||
|
lastResults = results;
|
||||||
const output = panel.querySelector('#test-output');
|
const output = panel.querySelector('#test-output');
|
||||||
const summary = panel.querySelector('#test-summary');
|
const summary = panel.querySelector('#test-summary');
|
||||||
let total = 0, passed = 0, failed = 0, totalTime = 0;
|
let total = 0, passed = 0, failed = 0, flaky = 0, totalTime = 0;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const suite of results) {
|
for (const suite of results) {
|
||||||
@@ -272,20 +361,33 @@ function renderResults(results) {
|
|||||||
for (const t of suite.tests) {
|
for (const t of suite.tests) {
|
||||||
total++;
|
total++;
|
||||||
totalTime += t.time;
|
totalTime += t.time;
|
||||||
if (t.status === 'pass') {
|
if (t.status === 'pass' && t.retried) {
|
||||||
|
// Failed first time, passed on auto-retry → flaky.
|
||||||
|
// Counts as passed in the summary but coloured amber +
|
||||||
|
// shows the original error so the user can decide whether
|
||||||
|
// to investigate or move on.
|
||||||
|
passed++;
|
||||||
|
flaky++;
|
||||||
|
html += `<div style="color:#fbbf24;padding-left:16px;">⚠ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span> <span style="color:#fbbf24;font-style:italic;">(flaky — passed on retry)</span></div>`;
|
||||||
|
if (t.originalError) {
|
||||||
|
html += `<div style="color:#fbbf24;padding-left:32px;font-size:11px;opacity:0.7;">first run: ${esc(t.originalError)}</div>`;
|
||||||
|
}
|
||||||
|
} else if (t.status === 'pass') {
|
||||||
passed++;
|
passed++;
|
||||||
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||||
} else {
|
} else {
|
||||||
failed++;
|
failed++;
|
||||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
const retryNote = t.retried ? ' <span style="color:#fb7185;font-style:italic;">(failed twice)</span>' : '';
|
||||||
|
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span>${retryNote}</div>`;
|
||||||
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
output.innerHTML = html;
|
output.innerHTML = html;
|
||||||
summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`;
|
const flakyNote = flaky ? `, ${flaky} flaky` : '';
|
||||||
summary.style.color = failed ? '#fb7185' : '#4ade80';
|
summary.textContent = `${passed}/${total} passed, ${failed} failed${flakyNote} — ${totalTime}ms`;
|
||||||
|
summary.style.color = failed ? '#fb7185' : (flaky ? '#fbbf24' : '#4ade80');
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
@@ -316,7 +418,46 @@ async function run() {
|
|||||||
// 4. Run tests
|
// 4. Run tests
|
||||||
const results = await runAll();
|
const results = await runAll();
|
||||||
|
|
||||||
// 5. Clean up any leftover UI state
|
// 4b. Auto-retry: collect every failed (suite, test) pair, restore the
|
||||||
|
// backup so the retry runs against the same baseline state the first
|
||||||
|
// pass started from, then re-run *only* those tests. Pass-on-retry is
|
||||||
|
// marked so the user can tell flaky from genuinely broken at a glance.
|
||||||
|
const failedKeys = new Set();
|
||||||
|
for (const sr of results) {
|
||||||
|
for (const t of sr.tests) {
|
||||||
|
if (t.status === 'fail') failedKeys.add(`${sr.name}::${t.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failedKeys.size) {
|
||||||
|
await closeAll();
|
||||||
|
try {
|
||||||
|
await restoreBackup();
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
} catch {}
|
||||||
|
const retryResults = await runAll(failedKeys);
|
||||||
|
const retryMap = new Map();
|
||||||
|
for (const sr of retryResults) {
|
||||||
|
for (const t of sr.tests) {
|
||||||
|
retryMap.set(`${sr.name}::${t.name}`, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sr of results) {
|
||||||
|
for (let i = 0; i < sr.tests.length; i++) {
|
||||||
|
const key = `${sr.name}::${sr.tests[i].name}`;
|
||||||
|
const retried = retryMap.get(key);
|
||||||
|
if (retried) {
|
||||||
|
const originalError = sr.tests[i].error;
|
||||||
|
sr.tests[i] = { ...retried, retried: true, originalError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Hide progress bar now that both passes (first + retry) are done
|
||||||
|
hideProgress();
|
||||||
|
|
||||||
|
// 6. Clean up any leftover UI state
|
||||||
await closeAll();
|
await closeAll();
|
||||||
|
|
||||||
// 6. Restore backup
|
// 6. Restore backup
|
||||||
@@ -351,8 +492,13 @@ if (new URLSearchParams(location.search).has('test')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
if (e.ctrlKey && e.shiftKey && e.altKey && (e.key === 'T' || e.key === 't')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
panel ? close() : open();
|
panel ? close() : open();
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && panel && panel.style.display !== 'none') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Search: Tag + Text Combined', () => {
|
describe('Search: Tag + Text Combined', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Search', () => {
|
describe('Search', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Settings: Open/Close', () => {
|
describe('Settings: Open/Close', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, $ } from './runner.js';
|
import { describe, it, expect, $ } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('App Shell', () => {
|
describe('App Shell', () => {
|
||||||
it('header with logo', () => {
|
it('header with logo', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
// Helper: simulate typing a snippet trigger in the editor body
|
// Helper: simulate typing a snippet trigger in the editor body
|
||||||
function typeSnippet(bodyEl, trigger) {
|
function typeSnippet(bodyEl, trigger) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, csrf } from './runner.js';
|
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||||
import { state, sortedCards } from '../../js/modules/state.js';
|
import { state, sortedCards } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Sorting: All Modes', () => {
|
describe('Sorting: All Modes', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
import { describe, it, expect } from './runner.js?v=4';
|
||||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../../js/modules/state.js';
|
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('State: parseTags', () => {
|
describe('State: parseTags', () => {
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
|
// Helper: create a bucket with the given name and return its row.
|
||||||
|
async function makeBucket(name) {
|
||||||
|
return fetch('api/buckets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchBucket(id, body) {
|
||||||
|
const res = await fetch(`api/buckets/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBucket(id) {
|
||||||
|
return fetch(`api/buckets/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrf() },
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBuckets() {
|
||||||
|
return fetch('api/buckets').then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Super-buckets: API parent_id', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
// These tests don't touch state.activeBucket but the bar-rendering
|
||||||
|
// suite below does — keep cleanup symmetric so reordering tests
|
||||||
|
// doesn't introduce a regression.
|
||||||
|
state.activeBucket = 'all';
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH parent_id nests one bucket under another', async () => {
|
||||||
|
const parent = await makeBucket('__sb_parent_a__');
|
||||||
|
const child = await makeBucket('__sb_child_a__');
|
||||||
|
const res = await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const all = await getBuckets();
|
||||||
|
const found = all.find(b => b.id === child.id);
|
||||||
|
expect(found.parent_id).toBe(parent.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH parent_id = null un-nests', async () => {
|
||||||
|
const parent = await makeBucket('__sb_parent_b__');
|
||||||
|
const child = await makeBucket('__sb_child_b__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
const res = await patchBucket(child.id, { parent_id: null });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const all = await getBuckets();
|
||||||
|
const found = all.find(b => b.id === child.id);
|
||||||
|
expect(found.parent_id).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses self as parent', async () => {
|
||||||
|
const b = await makeBucket('__sb_self__');
|
||||||
|
const res = await patchBucket(b.id, { parent_id: b.id });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('self');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses level 2 — bucket with own children cannot become a child', async () => {
|
||||||
|
const grandparent = await makeBucket('__sb_grand__');
|
||||||
|
const middle = await makeBucket('__sb_middle__');
|
||||||
|
const leaf = await makeBucket('__sb_leaf__');
|
||||||
|
// middle becomes a parent of leaf
|
||||||
|
await patchBucket(leaf.id, { parent_id: middle.id });
|
||||||
|
// Now try to make middle a child of grandparent — would create level 2
|
||||||
|
const res = await patchBucket(middle.id, { parent_id: grandparent.id });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error.toLowerCase()).toContain('children');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses nesting under a bucket that is itself a child', async () => {
|
||||||
|
const parent = await makeBucket('__sb_parent_c__');
|
||||||
|
const child = await makeBucket('__sb_child_c__');
|
||||||
|
const other = await makeBucket('__sb_other_c__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
// Try to nest `other` under `child` — child is already nested
|
||||||
|
const res = await patchBucket(other.id, { parent_id: child.id });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error.toLowerCase()).toContain('child');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE on a parent un-nests its children back to top-level', async () => {
|
||||||
|
const parent = await makeBucket('__sb_parent_d__');
|
||||||
|
const c1 = await makeBucket('__sb_child_d1__');
|
||||||
|
const c2 = await makeBucket('__sb_child_d2__');
|
||||||
|
await patchBucket(c1.id, { parent_id: parent.id });
|
||||||
|
await patchBucket(c2.id, { parent_id: parent.id });
|
||||||
|
await deleteBucket(parent.id);
|
||||||
|
const all = await getBuckets();
|
||||||
|
const f1 = all.find(b => b.id === c1.id);
|
||||||
|
const f2 = all.find(b => b.id === c2.id);
|
||||||
|
expect(f1).toExist();
|
||||||
|
expect(f2).toExist();
|
||||||
|
expect(f1.parent_id).toBeFalsy();
|
||||||
|
expect(f2.parent_id).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('export → import preserves parent_id', async () => {
|
||||||
|
const parent = await makeBucket('__sb_parent_e__');
|
||||||
|
const child = await makeBucket('__sb_child_e__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
|
||||||
|
// Round-trip through export → replace-import
|
||||||
|
const blob = await fetch('api/export').then(r => r.blob());
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'roundtrip.zip');
|
||||||
|
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
||||||
|
|
||||||
|
const all = await getBuckets();
|
||||||
|
const p = all.find(b => b.name === '__sb_parent_e__');
|
||||||
|
const c = all.find(b => b.name === '__sb_child_e__');
|
||||||
|
expect(p).toExist();
|
||||||
|
expect(c).toExist();
|
||||||
|
expect(c.parent_id).toBe(p.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Super-buckets: Bar rendering', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
// Reset activeBucket — several tests in this suite set it to a
|
||||||
|
// child id that wouldn't otherwise survive the run. Without this
|
||||||
|
// reset, downstream tests that create cards with bucket_id=null
|
||||||
|
// can't find them on the board (the activeBucket filter excludes
|
||||||
|
// them) and start failing in nondeterministic order.
|
||||||
|
state.activeBucket = 'all';
|
||||||
|
document.getElementById('bucket-super-popover')?.remove();
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a parent with one child renders a single super-tile in the bar', async () => {
|
||||||
|
const parent = await makeBucket('__sb_render_parent__');
|
||||||
|
const child = await makeBucket('__sb_render_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// The super-tile carries data-bucket-super=<parent.id>; the child is
|
||||||
|
// not rendered directly in the bar.
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
expect(superTile).toExist();
|
||||||
|
const childTile = document.querySelector(`#bucket-bar [data-bucket="${child.id}"]:not(#bucket-super-popover [data-bucket])`);
|
||||||
|
expect(childTile).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('super-tile shows the parent name when no child is active', async () => {
|
||||||
|
const parent = await makeBucket('__sb_label_parent__');
|
||||||
|
const child = await makeBucket('__sb_label_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
// Reset active filter so the parent is the default shown member.
|
||||||
|
state.activeBucket = 'all';
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
expect(superTile.textContent).toContain('__sb_label_parent__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('super-tile relabels to the active child name when a child is selected', async () => {
|
||||||
|
const parent = await makeBucket('__sb_active_parent__');
|
||||||
|
const child = await makeBucket('__sb_active_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
state.activeBucket = String(child.id);
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
expect(superTile.textContent).toContain('__sb_active_child__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the super-tile opens a popover with parent + child entries', async () => {
|
||||||
|
const parent = await makeBucket('__sb_pop_parent__');
|
||||||
|
const child1 = await makeBucket('__sb_pop_child1__');
|
||||||
|
const child2 = await makeBucket('__sb_pop_child2__');
|
||||||
|
await patchBucket(child1.id, { parent_id: parent.id });
|
||||||
|
await patchBucket(child2.id, { parent_id: parent.id });
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
click(superTile);
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
const popover = document.getElementById('bucket-super-popover');
|
||||||
|
expect(popover).toExist();
|
||||||
|
// Parent + 2 children = 3 entries
|
||||||
|
const entries = popover.querySelectorAll('[data-bucket]');
|
||||||
|
expect(entries.length).toBe(3);
|
||||||
|
// All three names appear
|
||||||
|
const text = popover.textContent;
|
||||||
|
expect(text).toContain('__sb_pop_parent__');
|
||||||
|
expect(text).toContain('__sb_pop_child1__');
|
||||||
|
expect(text).toContain('__sb_pop_child2__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a child entry in the popover sets activeBucket', async () => {
|
||||||
|
const parent = await makeBucket('__sb_act_parent__');
|
||||||
|
const child = await makeBucket('__sb_act_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
click(superTile);
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
|
||||||
|
expect(childEntry).toExist();
|
||||||
|
click(childEntry);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
expect(String(state.activeBucket)).toBe(String(child.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the super-tile twice toggles the popover closed', async () => {
|
||||||
|
const parent = await makeBucket('__sb_toggle_parent__');
|
||||||
|
const child = await makeBucket('__sb_toggle_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
click(superTile);
|
||||||
|
await sleep(100);
|
||||||
|
expect(document.getElementById('bucket-super-popover')).toExist();
|
||||||
|
click(superTile);
|
||||||
|
await sleep(100);
|
||||||
|
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regular bucket (no children) keeps its plain data-bucket tile', async () => {
|
||||||
|
const plain = await makeBucket('__sb_plain__');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
const tile = document.querySelector(`#bucket-bar [data-bucket="${plain.id}"]`);
|
||||||
|
expect(tile).toExist();
|
||||||
|
// Plain tile is NOT a super-tile
|
||||||
|
expect(tile.classList.contains('bucket-super')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Super-buckets: Drop cursor', () => {
|
||||||
|
afterEach(() => closeAll());
|
||||||
|
|
||||||
|
it('#bucket-drop-cursor element exists in the bar', async () => {
|
||||||
|
// Force a fresh render so the cursor element is appended
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(100);
|
||||||
|
const cursor = document.querySelector('#bucket-bar #bucket-drop-cursor');
|
||||||
|
expect(cursor).toExist();
|
||||||
|
// Hidden by default
|
||||||
|
expect(cursor.style.display === 'none' || cursor.style.display === '').toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,11 @@ const FILES = [
|
|||||||
'./toolbar.test.js',
|
'./toolbar.test.js',
|
||||||
'./buckets.test.js',
|
'./buckets.test.js',
|
||||||
'./bucket-context.test.js',
|
'./bucket-context.test.js',
|
||||||
|
'./super-buckets.test.js',
|
||||||
'./context-menu.test.js',
|
'./context-menu.test.js',
|
||||||
'./card-interactions.test.js',
|
'./card-interactions.test.js',
|
||||||
|
'./card-join.test.js',
|
||||||
|
'./board-find-replace.test.js',
|
||||||
'./filtering.test.js',
|
'./filtering.test.js',
|
||||||
'./sorting.test.js',
|
'./sorting.test.js',
|
||||||
'./state-functions.test.js',
|
'./state-functions.test.js',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Self-contained in-app test suite. No external dependencies.
|
|||||||
## Activation
|
## Activation
|
||||||
|
|
||||||
- **URL**: append `?test=1`
|
- **URL**: append `?test=1`
|
||||||
- **Keyboard**: `Ctrl+Shift+T`
|
- **Keyboard**: `Ctrl+Shift+Alt+T`
|
||||||
- **Safety**: automatic backup before run, auto-restore after
|
- **Safety**: automatic backup before run, auto-restore after
|
||||||
- **Visibility**: panel hides during tests so you can watch interactions, shows results after
|
- **Visibility**: panel hides during tests so you can watch interactions, shows results after
|
||||||
- **Sync paused**: background sync and autoSync disabled during tests
|
- **Sync paused**: background sync and autoSync disabled during tests
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Theme', () => {
|
describe('Theme', () => {
|
||||||
it('CSS custom properties set', () => {
|
it('CSS custom properties set', () => {
|
||||||
@@ -28,8 +28,90 @@ describe('Theme', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard Shortcuts', () => {
|
describe('Keyboard Shortcuts', () => {
|
||||||
it('Ctrl+Shift+T bound (test runner)', () => {
|
it('Ctrl+Shift+Alt+T bound (test runner)', () => {
|
||||||
// Just verify we got here — the test runner itself proves this works
|
// Just verify we got here — the test runner itself proves this works
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Theme: Design preset persistence', () => {
|
||||||
|
const STORAGE_KEY = 'hyprcrd-design-active-preset';
|
||||||
|
const DESIGN_KEY = 'hyprcrd-design';
|
||||||
|
let savedActive = null;
|
||||||
|
let savedDesign = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Restore the user's actual preset selection AND committed design.
|
||||||
|
// loadSelectedPreset() in the test fires commitDesign() which writes
|
||||||
|
// to hyprcrd-design — without this restore, running the test would
|
||||||
|
// permanently swap the user's design.
|
||||||
|
if (savedActive === null) localStorage.removeItem(STORAGE_KEY);
|
||||||
|
else localStorage.setItem(STORAGE_KEY, savedActive);
|
||||||
|
if (savedDesign === null) localStorage.removeItem(DESIGN_KEY);
|
||||||
|
else localStorage.setItem(DESIGN_KEY, savedDesign);
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
savedActive = localStorage.getItem(STORAGE_KEY);
|
||||||
|
savedDesign = localStorage.getItem(DESIGN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openThemeTab() {
|
||||||
|
click('#settings-btn');
|
||||||
|
await sleep(150);
|
||||||
|
const tab = $$('.settings-tab').find(t => t.dataset.tab === 'theme');
|
||||||
|
if (tab) click(tab);
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preset select exists in theme tab', async () => {
|
||||||
|
snapshot();
|
||||||
|
await openThemeTab();
|
||||||
|
expect($('#design-preset-select')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing preset writes active key to localStorage', async () => {
|
||||||
|
snapshot();
|
||||||
|
await openThemeTab();
|
||||||
|
const sel = $('#design-preset-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const opt = sel.querySelector('option[value^="builtin:"]');
|
||||||
|
if (!opt) return;
|
||||||
|
sel.value = opt.value;
|
||||||
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe(opt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopening settings restores the previously selected preset', async () => {
|
||||||
|
snapshot();
|
||||||
|
// Pre-seed an active preset before settings is ever opened in this test
|
||||||
|
await openThemeTab();
|
||||||
|
const sel = $('#design-preset-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const opts = [...sel.querySelectorAll('option[value^="builtin:"]')];
|
||||||
|
if (opts.length < 2) return;
|
||||||
|
const target = opts[1].value;
|
||||||
|
localStorage.setItem(STORAGE_KEY, target);
|
||||||
|
|
||||||
|
// Close + reopen settings to trigger initDesignSettings → refreshPresetSelect
|
||||||
|
click('#settings-close');
|
||||||
|
await sleep(150);
|
||||||
|
await openThemeTab();
|
||||||
|
|
||||||
|
const sel2 = $('#design-preset-select');
|
||||||
|
expect(sel2.value).toBe(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshPresetSelect ignores unknown preset keys', async () => {
|
||||||
|
snapshot();
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'user:__definitely_not_a_real_preset__');
|
||||||
|
await openThemeTab();
|
||||||
|
const sel = $('#design-preset-select');
|
||||||
|
if (!sel) return;
|
||||||
|
// Must not throw and must not assign the bogus value to the select
|
||||||
|
expect(sel.value).toBe(sel.value); // sanity: didn't crash above
|
||||||
|
expect(sel.value === 'user:__definitely_not_a_real_preset__').toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
import { state } from '../../js/modules/state.js';
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
describe('Toolbar: Filter Toggles', () => {
|
describe('Toolbar: Filter Toggles', () => {
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js';
|
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
function ctrlZ() {
|
function ctrlZ() {
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll `state.cards` until the card with the given id satisfies the
|
||||||
|
* predicate. Lets a test wait for an async click handler's _reloadAll
|
||||||
|
* to land in the in-memory state before triggering the next user
|
||||||
|
* action that reads from it. Use this in place of fixed `sleep(300)`
|
||||||
|
* waits between chained actions — fixed sleeps race against handler
|
||||||
|
* speed when the run executes faster than the original tuning.
|
||||||
|
*/
|
||||||
|
async function waitForStateCard(id, predicate, timeout = 2000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const card = state.cards.find(c => c.id == id);
|
||||||
|
if (card && predicate(card)) return card;
|
||||||
|
await sleep(30);
|
||||||
|
}
|
||||||
|
throw new Error(`waitForStateCard: card ${id} did not satisfy predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
describe('Undo: Color Change', () => {
|
describe('Undo: Color Change', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
|
|
||||||
@@ -112,7 +131,10 @@ describe('Undo: Editor Save', () => {
|
|||||||
body: JSON.stringify({ due_date: '2099-06-15' }),
|
body: JSON.stringify({ due_date: '2099-06-15' }),
|
||||||
});
|
});
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
// Make sure state.cards has the new due_date before opening the
|
||||||
|
// editor — the editor's snapshot for the undo entry reads from
|
||||||
|
// state.cards.find() and would otherwise capture due_date=null.
|
||||||
|
await waitForStateCard(res.id, c => c.due_date === '2099-06-15');
|
||||||
|
|
||||||
// Edit and change due date via editor
|
// Edit and change due date via editor
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
@@ -287,7 +309,12 @@ describe('Undo: Multiple Undos', () => {
|
|||||||
click($$('#ctx-items [data-action="color"]')[0]);
|
click($$('#ctx-items [data-action="color"]')[0]);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
||||||
await sleep(300);
|
// Wait for state.cards to reflect the new color before doing the
|
||||||
|
// *second* rightClick — otherwise the second action's contextmenu
|
||||||
|
// handler reads the stale card (#38bdf8) from state.cards and
|
||||||
|
// pushes the wrong undo snapshot, collapsing both undo entries
|
||||||
|
// onto the original color.
|
||||||
|
await waitForStateCard(res.id, c => c.color === '#fb7185');
|
||||||
|
|
||||||
// Change 2: color to green
|
// Change 2: color to green
|
||||||
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
||||||
@@ -296,7 +323,7 @@ describe('Undo: Multiple Undos', () => {
|
|||||||
click($$('#ctx-items [data-action="color"]')[0]);
|
click($$('#ctx-items [data-action="color"]')[0]);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
||||||
await sleep(300);
|
await waitForStateCard(res.id, c => c.color === '#4ade80');
|
||||||
|
|
||||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||||
expect(card.color).toBe('#4ade80');
|
expect(card.color).toBe('#4ade80');
|
||||||
|
|||||||
@@ -180,6 +180,86 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_messages (
|
|||||||
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
|
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
|
||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)');
|
||||||
|
// Read receipts: `fetched` (already present) is "delivered" — set when the
|
||||||
|
// recipient polls /api/chat. `seen` is "seen" — set when the recipient
|
||||||
|
// opens the thread with the sender via POST /api/chat/seen. The sender
|
||||||
|
// learns both states by polling GET /api/chat/sent.
|
||||||
|
$chatCols = $db->query('PRAGMA table_info(chat_messages)')->fetchAll();
|
||||||
|
$chatColNames = array_column($chatCols, 'name');
|
||||||
|
if (!in_array('seen', $chatColNames)) {
|
||||||
|
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
||||||
|
|
||||||
|
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
||||||
|
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
||||||
|
// the container for ongoing collaboration — cleanup only when deleted.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT "#38bdf8",
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_bucket_members (
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (bucket_uid, tenant_id),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_member_tenant ON collab_bucket_members(tenant_id, bucket_uid)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_bucket_updated ON collab_buckets(updated_at)');
|
||||||
|
|
||||||
|
// Collab cards: the actual synced content, stored at-rest under the
|
||||||
|
// inter-tenant key. Payload is an encrypted JSON blob containing title,
|
||||||
|
// body, color, tags, due_date, position. Tombstoned rows (deleted=1)
|
||||||
|
// survive for 7 days so late pollers see the deletion.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
author_username TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (bucket_uid, card_uid)
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
||||||
|
|
||||||
|
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
||||||
|
// editor for a collab card heartbeats into this table every 30s with a
|
||||||
|
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
||||||
|
// card. The lock is purely advisory — force-editing is still allowed and
|
||||||
|
// LWW remains the authoritative conflict resolver.
|
||||||
|
// Exclusive lock: PRIMARY KEY is (bucket_uid, card_uid). The first writer
|
||||||
|
// to acquire holds it; everyone else gets a 409 Locked. The previous schema
|
||||||
|
// had holder_id in the PK, which let multiple users "lock" the same card
|
||||||
|
// simultaneously — locks were advisory only, and concurrent writers could
|
||||||
|
// silently overwrite each other through LWW.
|
||||||
|
//
|
||||||
|
// Migration: if the existing table still has the 3-column PK, drop it.
|
||||||
|
// Locks are ephemeral (60s TTL) so dropping discards nothing important.
|
||||||
|
$lockTableInfo = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='collab_card_locks'")->fetchColumn();
|
||||||
|
if ($lockTableInfo && stripos($lockTableInfo, 'PRIMARY KEY (bucket_uid, card_uid, holder_id)') !== false) {
|
||||||
|
$db->exec('DROP TABLE collab_card_locks');
|
||||||
|
}
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks (
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
holder_id INTEGER NOT NULL,
|
||||||
|
holder_username TEXT NOT NULL,
|
||||||
|
acquired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (bucket_uid, card_uid),
|
||||||
|
FOREIGN KEY (holder_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -200,6 +280,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"
|
|||||||
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
||||||
// Chat: 7 days, longer than card sync but still ephemeral
|
// Chat: 7 days, longer than card sync but still ephemeral
|
||||||
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
||||||
|
// Collab-card tombstones: 7 days so late pollers still see the delete.
|
||||||
|
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
|
||||||
|
// Collab-card locks: drop everything past its TTL on every request.
|
||||||
|
$db->exec("DELETE FROM collab_card_locks WHERE expires_at < datetime('now')");
|
||||||
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
|
||||||
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
|
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
// Chat routes (client API only):
|
// Chat routes (client API only):
|
||||||
// POST /api/chat — send a chat message to {recipient}
|
// POST /api/chat — send a chat message to {recipient}
|
||||||
// GET /api/chat?since=N — pull all chat messages addressed to me with id > N
|
// GET /api/chat?since=N — pull all messages addressed to me (id > N)
|
||||||
|
// GET /api/chat/sent?since=N — pull state updates (delivered/seen) for
|
||||||
|
// *my* sent messages with id > N. The
|
||||||
|
// sender uses this to learn read receipts
|
||||||
|
// without needing a backchannel.
|
||||||
|
// POST /api/chat/seen {peer} — mark every message FROM peer TO me as
|
||||||
|
// seen. The sender's next /api/chat/sent
|
||||||
|
// poll picks the new state up.
|
||||||
//
|
//
|
||||||
// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the
|
// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the
|
||||||
// senders client secret, stored at-rest under the inter-tenant key, returned
|
// senders client secret, stored at-rest under the inter-tenant key, returned
|
||||||
@@ -80,3 +87,46 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /api/chat/sent ─────────────────────────────────────────
|
||||||
|
// Returns the receipt state (fetched/seen) for messages the caller has
|
||||||
|
// sent. Body is intentionally NOT included — the sender already has the
|
||||||
|
// plaintext locally; the relay only echoes the state flags so the sender
|
||||||
|
// can render check / double-check on the right messages.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/sent' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, recipient_id, fetched, seen, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE sender_id = :s AND id > :since
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1000
|
||||||
|
');
|
||||||
|
$stmt->execute(['s' => $clientTenant['id'], 'since' => $since]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$maxId = $since;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ((int)$r['id'] > $maxId) $maxId = (int)$r['id'];
|
||||||
|
}
|
||||||
|
jsonResponse(['receipts' => $rows, 'max_id' => $maxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/chat/seen ────────────────────────────────────────
|
||||||
|
// Body: { peer: "<sender_username>" }. Marks every message that the
|
||||||
|
// peer sent to the caller as seen=1. Idempotent — safe to call on
|
||||||
|
// every thread open.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/seen' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
if ($peer === '') jsonResponse(['error' => 'peer required'], 400);
|
||||||
|
$db->prepare('
|
||||||
|
UPDATE chat_messages
|
||||||
|
SET seen = 1
|
||||||
|
WHERE recipient_id = :r AND sender_username = :p AND seen = 0
|
||||||
|
')->execute(['r' => $clientTenant['id'], 'p' => $peer]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
// Collab bucket routes:
|
||||||
|
// POST /api/collab-buckets create {uid, name, color, members: [usernames]}
|
||||||
|
// GET /api/collab-buckets list buckets where the caller is a member
|
||||||
|
// PATCH /api/collab-buckets/{uid} owner-only: update name / color / members
|
||||||
|
// DELETE /api/collab-buckets/{uid} owner: delete bucket; non-owner: leave
|
||||||
|
//
|
||||||
|
// No body encryption on buckets — bucket metadata (name, color) is lightweight
|
||||||
|
// shared data. Cards inside the buckets (Phase 3) follow the chat decrypt-
|
||||||
|
// reencrypt-per-recipient pattern so content stays private on the relay.
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
// ── POST: create ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-buckets' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['uid'] ?? ''));
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$color = (string)($input['color'] ?? '#38bdf8');
|
||||||
|
$memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400);
|
||||||
|
// Prevent collisions (clients generate UUIDs so this is paranoia)
|
||||||
|
$exists = $db->prepare('SELECT 1 FROM collab_buckets WHERE uid = :u');
|
||||||
|
$exists->execute(['u' => $uid]);
|
||||||
|
if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409);
|
||||||
|
|
||||||
|
// Resolve requested members; silently drop unknown usernames
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($memberUsernames as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '') continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
// Always include the owner
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
$db->prepare('INSERT INTO collab_buckets (uid, owner_id, owner_username, name, color) VALUES (:uid, :oid, :ou, :n, :c)')
|
||||||
|
->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name, 'c' => $color]);
|
||||||
|
$mstmt = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$mstmt->execute(['b' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: list buckets I'm a member of ─────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-buckets' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (isRateLimited($db, 'collab-buckets-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT b.uid, b.name, b.color, b.owner_username, b.created_at, b.updated_at
|
||||||
|
FROM collab_buckets b
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = b.uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
ORDER BY b.updated_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $clientTenant['id']]);
|
||||||
|
$buckets = $stmt->fetchAll();
|
||||||
|
// Attach member list to each bucket
|
||||||
|
$mstmt = $db->prepare('SELECT username FROM collab_bucket_members WHERE bucket_uid = :uid ORDER BY username');
|
||||||
|
foreach ($buckets as &$b) {
|
||||||
|
$mstmt->execute(['uid' => $b['uid']]);
|
||||||
|
$b['members'] = array_column($mstmt->fetchAll(), 'username');
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'buckets' => $buckets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH: owner updates name / color / members ───────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$bucket = $row->fetch();
|
||||||
|
if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404);
|
||||||
|
if ((int)$bucket['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403);
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$sets = [];
|
||||||
|
$params = ['u' => $uid];
|
||||||
|
if (isset($input['name'])) { $sets[] = 'name = :n'; $params['n'] = trim((string)$input['name']); }
|
||||||
|
if (isset($input['color'])) { $sets[] = 'color = :c'; $params['c'] = (string)$input['color']; }
|
||||||
|
|
||||||
|
// Ownership transfer: new owner must already be a member of the bucket.
|
||||||
|
// We set owner_id/owner_username atomically and keep the current
|
||||||
|
// caller in the member list (no implicit leave on transfer).
|
||||||
|
if (isset($input['owner'])) {
|
||||||
|
$newOwnerName = trim((string)$input['owner']);
|
||||||
|
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
|
||||||
|
$newOwner = getTenantByUsername($db, $newOwnerName);
|
||||||
|
if (!$newOwner) jsonResponse(['error' => "no such user: $newOwnerName"], 404);
|
||||||
|
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
||||||
|
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
|
||||||
|
if (!$memCheck->fetchColumn()) {
|
||||||
|
jsonResponse(['error' => 'new owner must already be a member of the bucket'], 400);
|
||||||
|
}
|
||||||
|
$sets[] = 'owner_id = :oid'; $params['oid'] = (int)$newOwner['id'];
|
||||||
|
$sets[] = 'owner_username = :oun'; $params['oun'] = (string)$newOwner['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sets) {
|
||||||
|
$sets[] = "updated_at = datetime('now')";
|
||||||
|
$db->prepare('UPDATE collab_buckets SET ' . implode(', ', $sets) . ' WHERE uid = :u')->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($input['members']) && is_array($input['members'])) {
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($input['members'] as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '') continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
// Caller is always kept (the user editing) + the current effective
|
||||||
|
// owner (possibly just transferred in this same request).
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
if (isset($newOwner)) $resolved[(int)$newOwner['id']] = $newOwner['username'];
|
||||||
|
$db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$ins = $db->prepare('INSERT INTO collab_bucket_members (bucket_uid, tenant_id, username) VALUES (:b, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$ins->execute(['b' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
$db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: owner wipes; member leaves ────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM collab_buckets WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$bucket = $row->fetch();
|
||||||
|
if (!$bucket) jsonResponse(['error' => 'bucket not found'], 404);
|
||||||
|
|
||||||
|
if ((int)$bucket['owner_id'] === (int)$clientTenant['id']) {
|
||||||
|
// Owner: destroy everything
|
||||||
|
$db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'deleted']);
|
||||||
|
} else {
|
||||||
|
// Member: remove own membership only
|
||||||
|
$del = $db->prepare('DELETE FROM collab_bucket_members WHERE bucket_uid = :u AND tenant_id = :t');
|
||||||
|
$del->execute(['u' => $uid, 't' => $clientTenant['id']]);
|
||||||
|
// Bump updated_at so remaining members see the member-list change on next poll
|
||||||
|
$db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u")->execute(['u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'left']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
// Collab card routes (Phase 3):
|
||||||
|
// POST /api/collab-cards upsert a card in a collab bucket
|
||||||
|
// GET /api/collab-cards?since=<ts> pull deltas since a timestamp
|
||||||
|
// DELETE /api/collab-cards/{bucket}/{card} tombstone a card
|
||||||
|
//
|
||||||
|
// Encryption hop (same pattern as chat.php):
|
||||||
|
// POST: arrives encrypted with the sender's client secret → relay
|
||||||
|
// decrypts with that secret (via masterDecrypt of their
|
||||||
|
// secret_encrypted) → re-encrypts under the inter-tenant key →
|
||||||
|
// stored at rest.
|
||||||
|
// GET: for each row visible to the caller, decrypt from inter-tenant
|
||||||
|
// key → re-encrypt with the recipient's client secret → return.
|
||||||
|
//
|
||||||
|
// Membership check: every operation requires the caller to be a current
|
||||||
|
// member of the bucket_uid referenced. Drops silently when the caller
|
||||||
|
// leaves or is kicked.
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
/** Is $tenantId a member of $bucketUid right now? */
|
||||||
|
function collabCardsIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
|
||||||
|
$stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
||||||
|
$stmt->execute(['b' => $bucketUid, 't' => $tenantId]);
|
||||||
|
return (bool)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If a foreign tenant currently holds a non-expired lock on this card,
|
||||||
|
* return the lock row; otherwise null. Used to gate writes / deletes. */
|
||||||
|
function collabCardsForeignLock(PDO $db, string $bucketUid, string $cardUid, int $tenantId): ?array {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT holder_id, holder_username, expires_at
|
||||||
|
FROM collab_card_locks
|
||||||
|
WHERE bucket_uid = :b AND card_uid = :c
|
||||||
|
AND holder_id != :t
|
||||||
|
AND expires_at > datetime('now')
|
||||||
|
");
|
||||||
|
$stmt->execute(['b' => $bucketUid, 'c' => $cardUid, 't' => $tenantId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: upsert a card ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-cards' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (isRateLimited($db, 'collab-cards-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketUid = trim((string)($input['bucket_uid'] ?? ''));
|
||||||
|
$cardUid = trim((string)($input['card_uid'] ?? ''));
|
||||||
|
$encEnvelope = (string)($input['payload'] ?? '');
|
||||||
|
if ($bucketUid === '' || $cardUid === '' || $encEnvelope === '') {
|
||||||
|
jsonResponse(['error' => 'bucket_uid, card_uid, payload required'], 400);
|
||||||
|
}
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
// Exclusive lock enforcement: if another tenant holds the lock,
|
||||||
|
// refuse the write. Plugin retries on the next poll until the lock
|
||||||
|
// expires or is released. The local edit is preserved either way.
|
||||||
|
$foreign = collabCardsForeignLock($db, $bucketUid, $cardUid, (int)$clientTenant['id']);
|
||||||
|
if ($foreign) {
|
||||||
|
jsonResponse([
|
||||||
|
'error' => 'card is being edited by ' . $foreign['holder_username'],
|
||||||
|
'locked_by' => $foreign['holder_username'],
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||||
|
}
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($encEnvelope, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'payload decrypt failed (sender secret mismatch?)'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
// LWW: upsert with server-generated updated_at so clients all agree on
|
||||||
|
// the authoritative order (client clocks drift, server clock is the tie-
|
||||||
|
// breaker). Last writer wins.
|
||||||
|
$db->prepare('
|
||||||
|
INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted)
|
||||||
|
VALUES (:b, :c, :a, :p, datetime(\'now\'), 0)
|
||||||
|
ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET
|
||||||
|
author_username = excluded.author_username,
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
deleted = 0
|
||||||
|
')->execute([
|
||||||
|
'b' => $bucketUid,
|
||||||
|
'c' => $cardUid,
|
||||||
|
'a' => $clientTenant['username'],
|
||||||
|
'p' => $stored,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return the row's updated_at so the client can track what it pushed
|
||||||
|
$row = $db->prepare('SELECT updated_at FROM collab_cards WHERE bucket_uid = :b AND card_uid = :c');
|
||||||
|
$row->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
|
jsonResponse(['ok' => true, 'updated_at' => $row->fetchColumn()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: pull deltas since a given timestamp ─────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-cards' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||||
|
}
|
||||||
|
$since = trim((string)($_GET['since'] ?? ''));
|
||||||
|
|
||||||
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
// Join to membership so only buckets the caller is in right now are returned
|
||||||
|
$sql = '
|
||||||
|
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted
|
||||||
|
FROM collab_cards c
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
';
|
||||||
|
$params = ['tid' => $clientTenant['id']];
|
||||||
|
if ($since !== '') {
|
||||||
|
$sql .= ' AND c.updated_at > :since';
|
||||||
|
$params['since'] = $since;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY c.updated_at ASC LIMIT 1000';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$maxTs = $since;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
// Tombstones don't need payload — they just signal "delete locally"
|
||||||
|
if ((int)$row['deleted'] === 1) {
|
||||||
|
$out[] = [
|
||||||
|
'bucket_uid' => $row['bucket_uid'],
|
||||||
|
'card_uid' => $row['card_uid'],
|
||||||
|
'author_username' => $row['author_username'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
'deleted' => true,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
|
||||||
|
$reEncrypted = clientEncrypt($plain, $recipientSecret);
|
||||||
|
$out[] = [
|
||||||
|
'bucket_uid' => $row['bucket_uid'],
|
||||||
|
'card_uid' => $row['card_uid'],
|
||||||
|
'author_username' => $row['author_username'],
|
||||||
|
'payload' => $reEncrypted,
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
'deleted' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($row['updated_at'] > $maxTs) $maxTs = $row['updated_at'];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'cards' => $out, 'max_updated_at' => $maxTs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: tombstone a card ─────────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$bucketUid = $m[1];
|
||||||
|
$cardUid = $m[2];
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
// Same lock enforcement on delete: if another user has the editor
|
||||||
|
// open on this card, deleting it under their feet would be rude.
|
||||||
|
$foreign = collabCardsForeignLock($db, $bucketUid, $cardUid, (int)$clientTenant['id']);
|
||||||
|
if ($foreign) {
|
||||||
|
jsonResponse([
|
||||||
|
'error' => 'card is being edited by ' . $foreign['holder_username'],
|
||||||
|
'locked_by' => $foreign['holder_username'],
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
// Mark as tombstone rather than actually deleting. Late pollers see it,
|
||||||
|
// bootstrap.php cleanup prunes after 7 days.
|
||||||
|
$db->prepare('
|
||||||
|
INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted)
|
||||||
|
VALUES (:b, :c, :a, \'\', datetime(\'now\'), 1)
|
||||||
|
ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET
|
||||||
|
author_username = excluded.author_username,
|
||||||
|
payload = \'\',
|
||||||
|
updated_at = datetime(\'now\'),
|
||||||
|
deleted = 1
|
||||||
|
')->execute([
|
||||||
|
'b' => $bucketUid,
|
||||||
|
'c' => $cardUid,
|
||||||
|
'a' => $clientTenant['username'],
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
// Collab card lock routes (Phase 4 — advisory edit locks):
|
||||||
|
// POST /api/collab-locks acquire/refresh a lock
|
||||||
|
// DELETE /api/collab-locks/{bucket}/{card} release own lock
|
||||||
|
// GET /api/collab-locks list locks visible to caller
|
||||||
|
//
|
||||||
|
// Membership is enforced on every operation. A lock refresh just extends
|
||||||
|
// the TTL (60 seconds from now). Expired rows are pruned on every request
|
||||||
|
// via bootstrap.php cleanup, so the GET list never contains stale locks.
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
function collabLocksIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
|
||||||
|
$stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
||||||
|
$stmt->execute(['b' => $bucketUid, 't' => $tenantId]);
|
||||||
|
return (bool)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: acquire/refresh ────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-locks' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketUid = trim((string)($input['bucket_uid'] ?? ''));
|
||||||
|
$cardUid = trim((string)($input['card_uid'] ?? ''));
|
||||||
|
if ($bucketUid === '' || $cardUid === '') jsonResponse(['error' => 'bucket_uid and card_uid required'], 400);
|
||||||
|
if (!collabLocksIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
// Drop expired before checking — bootstrap cleanup runs every request
|
||||||
|
// but this protects against a stale lock blocking acquisition in the
|
||||||
|
// narrow window between cleanup and this query.
|
||||||
|
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')")
|
||||||
|
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
|
// Existing live lock? If a different tenant holds it, refuse.
|
||||||
|
$cur = $db->prepare('SELECT holder_id, holder_username, expires_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c');
|
||||||
|
$cur->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
|
$existing = $cur->fetch();
|
||||||
|
if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) {
|
||||||
|
jsonResponse([
|
||||||
|
'error' => 'card is being edited by ' . $existing['holder_username'],
|
||||||
|
'locked_by' => $existing['holder_username'],
|
||||||
|
'locked_by_id' => (int)$existing['holder_id'],
|
||||||
|
'expires_at' => $existing['expires_at'],
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
// Acquire / refresh — same holder bumps the expiry
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO collab_card_locks (bucket_uid, card_uid, holder_id, holder_username, acquired_at, expires_at)
|
||||||
|
VALUES (:b, :c, :hid, :hu, datetime('now'), datetime('now', '+60 seconds'))
|
||||||
|
ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET
|
||||||
|
holder_id = excluded.holder_id,
|
||||||
|
holder_username = excluded.holder_username,
|
||||||
|
expires_at = excluded.expires_at
|
||||||
|
")->execute([
|
||||||
|
'b' => $bucketUid,
|
||||||
|
'c' => $cardUid,
|
||||||
|
'hid' => $clientTenant['id'],
|
||||||
|
'hu' => $clientTenant['username'],
|
||||||
|
]);
|
||||||
|
jsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'holder_id' => (int)$clientTenant['id'],
|
||||||
|
'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE: release own lock ─────────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$bucketUid = $m[1];
|
||||||
|
$cardUid = $m[2];
|
||||||
|
$db->prepare('DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND holder_id = :h')
|
||||||
|
->execute(['b' => $bucketUid, 'c' => $cardUid, 'h' => $clientTenant['id']]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: list locks visible to caller ────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/collab-locks' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (isRateLimited($db, 'collab-locks-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
|
// Only return locks in buckets the caller is currently a member of,
|
||||||
|
// and only non-expired rows (bootstrap cleanup + defensive filter).
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT l.bucket_uid, l.card_uid, l.holder_id, l.holder_username, l.acquired_at, l.expires_at
|
||||||
|
FROM collab_card_locks l
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = l.bucket_uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
AND l.expires_at > datetime('now')
|
||||||
|
ORDER BY l.acquired_at DESC
|
||||||
|
");
|
||||||
|
$stmt->execute(['tid' => $clientTenant['id']]);
|
||||||
|
jsonResponse(['ok' => true, 'locks' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
@@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UI inbox view: session reads decrypted messages ──
|
||||||
|
//
|
||||||
|
// The relay already sees plaintext during the routing hop (decrypt with
|
||||||
|
// sender secret → re-encrypt with inter-tenant key for at-rest storage),
|
||||||
|
// so reading them back to the session user is no extra leakage. Lets the
|
||||||
|
// user check their inbox from the relay web UI on a phone without
|
||||||
|
// needing the full hyprcrd client. Does NOT mark messages as fetched —
|
||||||
|
// the canonical receiver is still the hyprcrd client.
|
||||||
|
|
||||||
|
if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200');
|
||||||
|
$stmt->execute(['r' => $sessionTenant['id']]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'sender' => $row['sender_username'] ?? '?',
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'fetched' => (int)$row['fetched'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['messages' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||||
|
|
||||||
$clientTenant = authClient($db);
|
$clientTenant = authClient($db);
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
require __DIR__ . '/inc/routes/admin.php';
|
require __DIR__ . '/inc/routes/admin.php';
|
||||||
require __DIR__ . '/inc/routes/messages.php';
|
require __DIR__ . '/inc/routes/messages.php';
|
||||||
require __DIR__ . '/inc/routes/chat.php';
|
require __DIR__ . '/inc/routes/chat.php';
|
||||||
|
require __DIR__ . '/inc/routes/collab-buckets.php';
|
||||||
|
require __DIR__ . '/inc/routes/collab-cards.php';
|
||||||
|
require __DIR__ . '/inc/routes/collab-locks.php';
|
||||||
|
|
||||||
jsonResponse(['error' => 'not found'], 404);
|
jsonResponse(['error' => 'not found'], 404);
|
||||||
}
|
}
|
||||||
|
|||||||
+276
-44
@@ -5,12 +5,51 @@ let allTenants = [];
|
|||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
function toast(msg, isError = false) {
|
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
|
||||||
const t = $('toast');
|
* Multiple toasts stack vertically, oldest at the top, and each one
|
||||||
t.textContent = msg;
|
* slides in from the right + fades out after a fixed timeout. The
|
||||||
t.classList.toggle('err', isError);
|
* legacy `toast(msg, true)` second-arg style still works (true → err). */
|
||||||
t.classList.add('show');
|
/** Custom confirm dialog — returns a Promise<boolean>. Replaces native
|
||||||
setTimeout(() => t.classList.remove('show'), 2200);
|
* confirm() across the relay UI so all confirmations are styled. */
|
||||||
|
function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const el = $('relay-confirm');
|
||||||
|
if (!el) { resolve(confirm(msg)); return; } // fallback
|
||||||
|
$('relay-confirm-msg').textContent = msg;
|
||||||
|
const yesBtn = $('relay-confirm-yes');
|
||||||
|
yesBtn.textContent = okLabel;
|
||||||
|
yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
const cleanup = (result) => {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners
|
||||||
|
$('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true));
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
$('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true });
|
||||||
|
$('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, kind = 'info') {
|
||||||
|
if (kind === true) kind = 'err';
|
||||||
|
if (kind === false) kind = 'info';
|
||||||
|
const stack = $('toast-stack');
|
||||||
|
if (!stack) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast ' + kind;
|
||||||
|
const icon = kind === 'err' ? 'ph-warning-circle'
|
||||||
|
: kind === 'ok' ? 'ph-check-circle'
|
||||||
|
: 'ph-info';
|
||||||
|
el.innerHTML = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
|
||||||
|
el.querySelector('.toast-msg').textContent = msg;
|
||||||
|
stack.appendChild(el);
|
||||||
|
// Trigger the slide-in next frame so the transition runs
|
||||||
|
requestAnimationFrame(() => el.classList.add('show'));
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.add('fade-out');
|
||||||
|
setTimeout(() => el.remove(), 350);
|
||||||
|
}, 2800);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
@@ -29,10 +68,6 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
name = 'change-password';
|
||||||
}
|
}
|
||||||
// Block admin view on mobile — even if someone tries programmatically
|
|
||||||
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
|
|
||||||
name = 'compose';
|
|
||||||
}
|
|
||||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
@@ -40,20 +75,28 @@ function showView(name) {
|
|||||||
|
|
||||||
if (['compose', 'account', 'admin'].includes(name)) {
|
if (['compose', 'account', 'admin'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-buttons').style.display = 'flex';
|
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||||
|
$('nav-burger')?.classList.remove('hidden');
|
||||||
|
// Picking a view also collapses the mobile menu so it doesn't
|
||||||
|
// hover over the freshly-shown view.
|
||||||
|
$('nav-buttons').classList.remove('menu-open');
|
||||||
} else {
|
} else {
|
||||||
// Setup, login, and forced change-password hide all nav
|
// Setup, login, and forced change-password hide all nav
|
||||||
$('nav-buttons').classList.add('hidden');
|
$('nav-buttons').classList.add('hidden');
|
||||||
|
$('nav-burger')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
loadInbox();
|
loadInbox();
|
||||||
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
$('compose-body')?.focus();
|
||||||
} else if (name === 'admin') {
|
} else {
|
||||||
|
stopInboxPoll();
|
||||||
|
}
|
||||||
|
if (name === 'admin') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (name === 'account') {
|
} else if (name === 'account') {
|
||||||
loadAccountSecret();
|
loadAccountSecret();
|
||||||
@@ -63,6 +106,64 @@ function showView(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbox auto-poll ──
|
||||||
|
//
|
||||||
|
// Refresh the inbox every 15s while the compose view is showing so new
|
||||||
|
// messages from other tenants surface without a manual reload. Stops
|
||||||
|
// the moment the user navigates away (account/admin/login) so we don't
|
||||||
|
// hammer the server in the background.
|
||||||
|
let inboxPollHandle = null;
|
||||||
|
function startInboxPoll() {
|
||||||
|
stopInboxPoll();
|
||||||
|
inboxPollHandle = setInterval(() => {
|
||||||
|
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
function stopInboxPoll() {
|
||||||
|
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
||||||
|
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
||||||
|
* positioned under the header. */
|
||||||
|
function toggleNavMenu() {
|
||||||
|
$('nav-buttons')?.classList.toggle('menu-open');
|
||||||
|
}
|
||||||
|
window.toggleNavMenu = toggleNavMenu;
|
||||||
|
|
||||||
|
/** Tap-target on the header inbox count: switches to the compose view
|
||||||
|
* if needed, then smooth-scrolls #inbox-section into view and briefly
|
||||||
|
* highlights it so the user can't miss it after the scroll lands.
|
||||||
|
* Same handler on desktop and mobile — desktop just scrolls a few px
|
||||||
|
* if at all (the inbox is already on screen). */
|
||||||
|
function jumpToInbox() {
|
||||||
|
if (!me) return;
|
||||||
|
if ($('view-compose')?.classList.contains('hidden')) {
|
||||||
|
showView('compose');
|
||||||
|
}
|
||||||
|
// Wait one frame so the (possibly just-shown) view has measurable
|
||||||
|
// dimensions before scrollIntoView fires
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const target = $('inbox-section');
|
||||||
|
if (!target) return;
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
target.classList.remove('flash-highlight');
|
||||||
|
// Restart the animation by toggling the class
|
||||||
|
void target.offsetWidth;
|
||||||
|
target.classList.add('flash-highlight');
|
||||||
|
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.jumpToInbox = jumpToInbox;
|
||||||
|
// Tap outside the menu closes it
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const nav = $('nav-buttons');
|
||||||
|
const burger = $('nav-burger');
|
||||||
|
if (!nav?.classList.contains('menu-open')) return;
|
||||||
|
if (nav.contains(e.target) || burger?.contains(e.target)) return;
|
||||||
|
nav.classList.remove('menu-open');
|
||||||
|
});
|
||||||
|
|
||||||
async function doChangePassword() {
|
async function doChangePassword() {
|
||||||
const current = $('cp-current').value;
|
const current = $('cp-current').value;
|
||||||
const newPw = $('cp-new').value;
|
const newPw = $('cp-new').value;
|
||||||
@@ -172,7 +273,9 @@ async function refreshMe() {
|
|||||||
};
|
};
|
||||||
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
||||||
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
||||||
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
|
const n = status.inbox_count || 0;
|
||||||
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
|
||||||
|
$('header-status').classList.toggle('has-unread', n > 0);
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -185,31 +288,53 @@ async function loadTenants() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateRecipientPicker() {
|
function populateRecipientPicker() {
|
||||||
const sel = $('compose-recipient');
|
const hidden = $('compose-recipient');
|
||||||
|
const btn = $('compose-recipient-btn');
|
||||||
|
const label = $('compose-recipient-label');
|
||||||
|
const dd = $('compose-recipient-dropdown');
|
||||||
const field = $('compose-recipient-field');
|
const field = $('compose-recipient-field');
|
||||||
sel.innerHTML = '';
|
if (!hidden || !btn || !dd) return;
|
||||||
|
|
||||||
// Always include "you" first as the default recipient (the value
|
// Build the options list: self first, then all other tenants
|
||||||
// the form submits even when the picker is hidden)
|
const options = [];
|
||||||
if (me) {
|
if (me) options.push({ value: me.username, text: `${me.username} (you)` });
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = me.username;
|
|
||||||
opt.textContent = `${me.username} (you)`;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
for (const t of allTenants) {
|
for (const t of allTenants) {
|
||||||
if (me && t.username === me.username) continue;
|
if (me && t.username === me.username) continue;
|
||||||
const opt = document.createElement('option');
|
options.push({ value: t.username, text: t.username });
|
||||||
opt.value = t.username;
|
|
||||||
opt.textContent = t.username;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solo tenant → no choice to make. Hide the picker entirely so
|
// Default to self
|
||||||
// the compose form is just title + body.
|
if (options.length && !hidden.value) {
|
||||||
if (field) {
|
hidden.value = options[0].value;
|
||||||
field.classList.toggle('hidden', allTenants.length <= 1);
|
label.textContent = options[0].text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render dropdown items
|
||||||
|
dd.innerHTML = options.map(o => `
|
||||||
|
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
|
||||||
|
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
dd.querySelectorAll('.recipient-option').forEach(opt => {
|
||||||
|
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)');
|
||||||
|
opt.addEventListener('mouseleave', () => opt.style.background = 'none');
|
||||||
|
opt.addEventListener('click', () => {
|
||||||
|
hidden.value = opt.dataset.value;
|
||||||
|
label.textContent = opt.textContent.trim();
|
||||||
|
dd.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle dropdown on button click
|
||||||
|
btn.onclick = () => dd.classList.toggle('hidden');
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Solo tenant → hide the picker
|
||||||
|
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCompose() {
|
function clearCompose() {
|
||||||
@@ -247,31 +372,138 @@ async function sendCompose() {
|
|||||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||||
|
|
||||||
async function loadInbox() {
|
async function loadInbox() {
|
||||||
const status = await api('api/status');
|
|
||||||
$('inbox-count').textContent = status.inbox_count || 0;
|
|
||||||
const list = $('inbox-list');
|
const list = $('inbox-list');
|
||||||
if (!status.inbox_count) {
|
list.innerHTML = `
|
||||||
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||||
} else {
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||||
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
|
<div class="skeleton-row"><div class="skel"></div></div>
|
||||||
|
`;
|
||||||
|
const res = await api('api/messages/inbox');
|
||||||
|
const messages = res?.messages || [];
|
||||||
|
$('inbox-count').textContent = messages.length;
|
||||||
|
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
||||||
|
// Mirror count into the header status so the unread dot stays in sync
|
||||||
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||||
|
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||||
|
|
||||||
|
if (!messages.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-tray icon"></i>
|
||||||
|
<div class="label">inbox empty</div>
|
||||||
|
<div class="hint">Messages from other tenants will show up here.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
list.innerHTML = messages.map(m => `
|
||||||
|
<div class="inbox-item" data-id="${m.id}">
|
||||||
|
<div class="content">
|
||||||
|
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
|
||||||
|
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
||||||
|
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(id, btn) {
|
||||||
|
// Inline confirmation: replace the trash button with yes/no.
|
||||||
|
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||||
|
// passing escaped HTML through attributes.
|
||||||
|
const row = btn.closest('.inbox-item');
|
||||||
|
if (!row) return;
|
||||||
|
const origHtml = btn.outerHTML;
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'del-confirm';
|
||||||
|
wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<span style="color:var(--danger);">delete?</span>
|
||||||
|
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
|
||||||
|
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
|
||||||
|
`;
|
||||||
|
btn.replaceWith(wrap);
|
||||||
|
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
||||||
|
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||||
|
if (res?.ok) {
|
||||||
|
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
||||||
|
loadInbox();
|
||||||
|
refreshMe();
|
||||||
|
toast('message deleted', 'ok');
|
||||||
|
} else {
|
||||||
|
toast(res?.error || 'delete failed', 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wrap.querySelector('.del-no').addEventListener('click', () => {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = origHtml;
|
||||||
|
wrap.replaceWith(temp.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.deleteMessage = deleteMessage;
|
||||||
|
|
||||||
|
async function deleteAllMessages() {
|
||||||
|
const res = await api('api/messages/inbox');
|
||||||
|
const messages = res?.messages || [];
|
||||||
|
if (!messages.length) return;
|
||||||
|
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
|
||||||
|
let ok = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
|
||||||
|
if (r?.ok) ok++;
|
||||||
|
}
|
||||||
|
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
||||||
|
loadInbox();
|
||||||
|
refreshMe();
|
||||||
|
}
|
||||||
|
window.deleteAllMessages = deleteAllMessages;
|
||||||
|
|
||||||
|
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||||
|
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||||
|
* or a full date for older messages. */
|
||||||
|
function formatRelTime(sqliteUtc) {
|
||||||
|
if (!sqliteUtc) return '';
|
||||||
|
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||||
|
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(t.getTime())) return sqliteUtc;
|
||||||
|
const diff = (Date.now() - t.getTime()) / 1000;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||||
|
if (diff < 86400) {
|
||||||
|
const today = new Date();
|
||||||
|
const sameDay = today.toDateString() === t.toDateString();
|
||||||
|
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
|
||||||
|
return Math.floor(diff / 3600) + ' h ago';
|
||||||
|
}
|
||||||
|
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
|
||||||
|
return t.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin: users ──
|
// ── Admin: users ──
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
const list = $('users-list');
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||||
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||||
|
<div class="skeleton-row"><div class="skel"></div></div>
|
||||||
|
`;
|
||||||
const res = await api('api/users');
|
const res = await api('api/users');
|
||||||
if (res.error) { toast(res.error, true); return; }
|
if (res.error) { toast(res.error, 'err'); return; }
|
||||||
const users = res.users || [];
|
const users = res.users || [];
|
||||||
$('users-count').textContent = users.length;
|
$('users-count').textContent = users.length;
|
||||||
const list = $('users-list');
|
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
list.innerHTML = '<div class="empty">no users yet</div>';
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-users icon"></i>
|
||||||
|
<div class="label">no users yet</div>
|
||||||
|
<div class="hint">Create the first one with the form on the right.</div>
|
||||||
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = users.map(u => `
|
list.innerHTML = users.map(u => `
|
||||||
<div class="user-row" data-id="${u.id}">
|
<div class="user-row" data-id="${u.id}">
|
||||||
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
|
<i class="ph ph-user-circle user-icon"></i>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||||
<div class="meta">created ${u.created_at}</div>
|
<div class="meta">created ${u.created_at}</div>
|
||||||
@@ -315,7 +547,7 @@ async function resetUserPassword(id, username) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id, username) {
|
async function deleteUser(id, username) {
|
||||||
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
|
if (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) return;
|
||||||
const res = await api('api/users/' + id, { method: 'DELETE' });
|
const res = await api('api/users/' + id, { method: 'DELETE' });
|
||||||
if (res.ok) { toast('user deleted'); loadUsers(); }
|
if (res.ok) { toast('user deleted'); loadUsers(); }
|
||||||
else toast(res.error || 'failed', true);
|
else toast(res.error || 'failed', true);
|
||||||
@@ -382,7 +614,7 @@ async function saveAccountSecret() {
|
|||||||
|
|
||||||
async function generateAccountSecret() {
|
async function generateAccountSecret() {
|
||||||
if (currentAccountSecret) {
|
if (currentAccountSecret) {
|
||||||
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
|
if (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) return;
|
||||||
}
|
}
|
||||||
setAccountStatus('generating...');
|
setAccountStatus('generating...');
|
||||||
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
||||||
@@ -399,7 +631,7 @@ async function generateAccountSecret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearAccountSecret() {
|
async function clearAccountSecret() {
|
||||||
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
|
if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) return;
|
||||||
const res = await api('api/account/secret', { method: 'DELETE' });
|
const res = await api('api/account/secret', { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setAccountStatus('cleared', 'ok');
|
setAccountStatus('cleared', 'ok');
|
||||||
|
|||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user