Compare commits
48
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 | ||
|
|
f886ded054 | ||
|
|
22f73ab3b9 | ||
|
|
5af49655d6 | ||
|
|
3ae84ccdd7 | ||
|
|
262b853c5b | ||
|
|
988a5410f7 | ||
|
|
ef238f3b9c | ||
|
|
5b18c46ac2 | ||
|
|
7219bae655 | ||
|
|
dd1afbbeb2 | ||
|
|
86508ffcca | ||
|
|
cc97754ae6 |
No files matched your search
@@ -8,3 +8,6 @@
|
||||
*.swo
|
||||
.DS_Store
|
||||
*.zip
|
||||
electron/node_modules
|
||||
data/hyprcrd.db-shm
|
||||
data/hyprcrd.db-wal
|
||||
@@ -342,30 +342,37 @@ When running multiple hyprcrd instances that share CalDAV credentials, you need
|
||||
|
||||
## Relay
|
||||
|
||||
A minimal web service for mobile note capture and card sharing between hyprcrd instances.
|
||||
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
||||
share one relay instance — each one has their own login, their own
|
||||
client encryption secret, and their own inbox. Cards (and in the future:
|
||||
encrypted chat messages) can be sent **between tenants** on the same
|
||||
relay.
|
||||
|
||||
**Full documentation:** [`relay/README.md`](relay/README.md)
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
[Phone/Browser] → https://relay.example.com → [Relay Service]
|
||||
↓ poll
|
||||
[hyprcrd (local)]
|
||||
[hyprcrd alice] ──push──┐ ┌──poll── [hyprcrd alice]
|
||||
├──► [Relay] ◄────────────┤
|
||||
[hyprcrd bob] ──push──┘ tenants: alice, bob └──poll── [hyprcrd bob]
|
||||
|
||||
[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B]
|
||||
sender encrypts → relay decrypts with sender's secret →
|
||||
re-encrypts with shared inter-tenant key → stores →
|
||||
on recipient poll: decrypts → re-encrypts with recipient's secret
|
||||
```
|
||||
|
||||
### Deploy to Webspace
|
||||
### Deploy
|
||||
|
||||
**Webspace** (any PHP host with `mod_rewrite` and `pdo_sqlite`):
|
||||
|
||||
```bash
|
||||
cd relay
|
||||
./dist.sh
|
||||
# upload hyprcrd-relay.zip and extract into your webspace
|
||||
```
|
||||
|
||||
Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret.
|
||||
|
||||
Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`.
|
||||
|
||||
### Deploy with Docker
|
||||
**Docker**:
|
||||
|
||||
```bash
|
||||
cd relay
|
||||
@@ -373,42 +380,76 @@ docker build -t hyprcrd-relay .
|
||||
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
|
||||
```
|
||||
|
||||
The `data/` directory is created on first request and contains the
|
||||
SQLite DB plus the master encryption key (chmod 600, blocked from web
|
||||
access via `.htaccess`).
|
||||
|
||||
### Development (both together)
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding.
|
||||
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
||||
|
||||
From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
||||
### Setup workflow
|
||||
|
||||
### Setup
|
||||
1. Open the relay URL **from a desktop browser** — the setup wizard
|
||||
is desktop-friendly
|
||||
2. Create the first **admin** account: username + password (UI login)
|
||||
+ shared secret (client encryption)
|
||||
3. As admin, click the **users** tab and create accounts for any
|
||||
additional users you want to give relay access to
|
||||
4. Each user enters their credentials in their own hyprcrd:
|
||||
**Settings → Relay** → relay URL + username + shared secret
|
||||
5. To send a card from one tenant to another, use the relay UI's
|
||||
compose form (recipient picker) — or push cards from hyprcrd
|
||||
|
||||
1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption)
|
||||
2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret
|
||||
3. Click **save**
|
||||
### How it works (high level)
|
||||
|
||||
### How it works
|
||||
|
||||
- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag
|
||||
- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll
|
||||
- Messages are encrypted client-side (AES-256-CBC) before sending to the relay
|
||||
- The relay only stores ciphertext — it cannot read your messages
|
||||
- hyprcrd polls the relay every 45 seconds in the background
|
||||
- Messages are kept on the relay for 24 hours, then auto-deleted
|
||||
- Cards pushed from the same instance are not re-imported
|
||||
- Enter to send, Shift+Enter for newline
|
||||
- **Per-tenant inboxes**: each user only sees their own messages
|
||||
- **Cross-tenant routing**: send a card to another tenant on the same
|
||||
relay; the relay decrypts with the sender's secret, re-encrypts with
|
||||
a shared inter-tenant key for storage, then re-encrypts with the
|
||||
recipient's secret on delivery
|
||||
- **At-rest encryption**: every sensitive value in the DB (tenant
|
||||
secrets, the inter-tenant key, message bodies) is encrypted. The
|
||||
master key lives outside the DB in a chmod-600 file
|
||||
- **24-hour ephemeral**: messages and attachments auto-delete after a
|
||||
day. Cards extracted in hyprcrd are the long-term store
|
||||
- **Future: end-to-end encrypted chat** between tenants is planned and
|
||||
will require per-pair key exchange — see `relay/README.md` for the
|
||||
roadmap
|
||||
|
||||
### Relay Security
|
||||
|
||||
- Separate password (UI login) and shared secret (API + encryption)
|
||||
- Bcrypt hashing for both credentials
|
||||
- Rate-limited login (5 attempts/minute)
|
||||
- AES-256-CBC client-side encryption — relay never sees plaintext
|
||||
- Per-tenant bcrypt-hashed passwords + secrets
|
||||
- Master key encryption for tenant secrets and the inter-tenant key
|
||||
at rest
|
||||
- HttpOnly + SameSite=Strict session cookies
|
||||
- Rate-limited login (5 attempts/minute per IP)
|
||||
- AES-256-CBC for all message encryption
|
||||
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
|
||||
- SQLite database blocked from direct download via `.htaccess`
|
||||
- Request size limited to 256KB
|
||||
- `data/` directory blocked from direct download via `.htaccess`
|
||||
- Request size limited to 256KB (15MB for attachments)
|
||||
- Last-admin protection prevents accidental lockout
|
||||
|
||||
For the full security model (including what's NOT protected and the
|
||||
roadmap to strict E2E for chat), see [`relay/README.md`](relay/README.md).
|
||||
|
||||
### Migrating from the old single-tenant relay
|
||||
|
||||
When you upgrade an existing relay installation, the new code automatically
|
||||
detects the legacy schema and drops the old `messages` and `attachments`
|
||||
tables (the data is 24h-ephemeral anyway). The setup wizard appears on
|
||||
your next visit. You'll need to:
|
||||
1. Run through the desktop setup wizard to create the first admin
|
||||
2. Recreate any other tenant accounts that need access
|
||||
3. On every connected hyprcrd, update **Settings → Relay** to add the
|
||||
new **username** field and the new shared secret
|
||||
|
||||
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
|
||||
client secrets need to be reissued.
|
||||
|
||||
## Rebuilding Tailwind CSS
|
||||
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.7.1');
|
||||
define('APP_VERSION', '1.1.0');
|
||||
|
||||
// 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)
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"disabled": [
|
||||
"logseq-import"
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ services:
|
||||
- ./relay/index.php:/var/www/html/index.php
|
||||
- ./relay/.htaccess:/var/www/html/.htaccess
|
||||
- ./relay/fonts:/var/www/html/fonts
|
||||
- ./relay/inc:/var/www/html/inc
|
||||
- ./relay/ui:/var/www/html/ui
|
||||
- relay-data:/var/www/html/data
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -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';
|
||||
+471
-41
@@ -1,5 +1,218 @@
|
||||
body { font-family: 'JetBrains Mono', monospace; }
|
||||
.font-pixel { font-family: var(--t-font-pixel, 'Silkscreen', cursive) !important; }
|
||||
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
||||
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
|
||||
.brand { font-family: 'Silkscreen', cursive !important; }
|
||||
body.no-scanlines::after { display: none !important; }
|
||||
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
||||
|
||||
/* ── User-customizable design tokens ── */
|
||||
:root {
|
||||
--ui-radius-card: 0px;
|
||||
--ui-radius-button: 0px;
|
||||
--ui-radius-input: 0px;
|
||||
--ui-radius-modal: 4px;
|
||||
--ui-radius-tag: 2px;
|
||||
--ui-radius-image: 4px;
|
||||
--ui-border-card: 1px;
|
||||
--ui-border-style: solid;
|
||||
--ui-bar-height: 3px;
|
||||
--ui-pad-card: 16px;
|
||||
--ui-gap-board: 0px;
|
||||
--ui-shadow-card: none;
|
||||
--ui-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.35);
|
||||
--ui-shadow-header: 0 4px 20px -4px rgba(0,0,0,0.3);
|
||||
--ui-shadow-footer: 0 -4px 20px -4px rgba(0,0,0,0.3);
|
||||
--ui-lift-card: 0px;
|
||||
--ui-blur-overlay: 0px;
|
||||
}
|
||||
|
||||
#main-header { box-shadow: var(--ui-shadow-header); }
|
||||
#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 */
|
||||
.board-card {
|
||||
--card-stack-shadow: 0 0 0 0 transparent;
|
||||
border-radius: var(--ui-radius-card);
|
||||
border-width: var(--ui-border-card);
|
||||
border-style: var(--ui-border-style);
|
||||
box-shadow: var(--ui-shadow-card), var(--card-stack-shadow);
|
||||
transition: border-color 0.12s, transform 0.15s, box-shadow 0.2s;
|
||||
overflow: hidden; /* clip card-bar (and other inner elements) to the rounded corners */
|
||||
}
|
||||
.board-card.card-group {
|
||||
--card-stack-shadow:
|
||||
0 3px 0 -2px var(--t-border),
|
||||
0 3px 0 0 var(--t-bg),
|
||||
0 8px 0 -4px color-mix(in srgb, var(--t-border) 70%, transparent),
|
||||
0 6px 0 0 var(--t-bg),
|
||||
0 13px 0 -6px color-mix(in srgb, var(--t-border) 30%, transparent);
|
||||
}
|
||||
.board-card:hover { transform: translateY(calc(0px - var(--ui-lift-card))); }
|
||||
.board-card > .p-4 { padding: var(--ui-pad-card); }
|
||||
.board-card .card-tags { flex-shrink: 0; min-height: 18px; }
|
||||
#board { gap: var(--ui-gap-board); }
|
||||
|
||||
/* Bordered action buttons */
|
||||
button[class*="border-t-border"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||
button[class*="border-t-accent"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||
button[class*="border-t-danger"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||
a[class*="border-t-border"],
|
||||
a[class*="border-t-accent"] {
|
||||
border-radius: var(--ui-radius-button);
|
||||
}
|
||||
|
||||
/* Inputs / selects / textareas */
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"])[class*="border-t-border"],
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"])[class*="border-t-accent"],
|
||||
textarea[class*="border-t-border"],
|
||||
textarea[class*="border-t-accent"],
|
||||
select[class*="border-t-border"],
|
||||
select[class*="border-t-accent"] {
|
||||
border-radius: var(--ui-radius-input);
|
||||
}
|
||||
|
||||
/* Tag chips */
|
||||
.tag-chip { border-radius: var(--ui-radius-tag); }
|
||||
|
||||
/* Prose images & card cover */
|
||||
.prose img { border-radius: var(--ui-radius-image); }
|
||||
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
||||
|
||||
/* Modal containers (the inner box of each overlay) */
|
||||
#modal > div,
|
||||
#detail > div,
|
||||
#settings-overlay > div,
|
||||
#help > div,
|
||||
#search > div,
|
||||
#tasks > div,
|
||||
#calendar > div,
|
||||
#gallery > div,
|
||||
#dialog > div {
|
||||
border-radius: var(--ui-radius-modal);
|
||||
box-shadow: var(--ui-shadow-modal);
|
||||
}
|
||||
|
||||
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
||||
.overlay { backdrop-filter: blur(var(--ui-blur-overlay)); -webkit-backdrop-filter: blur(var(--ui-blur-overlay)); }
|
||||
|
||||
/* Animation speed (toggled via body class from applyDesign) */
|
||||
body.anim-off *, body.anim-off *::before, body.anim-off *::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
body.anim-fast *, body.anim-fast *::before, body.anim-fast *::after {
|
||||
transition-duration: 50ms !important;
|
||||
animation-duration: 100ms !important;
|
||||
}
|
||||
body.anim-slow *, body.anim-slow *::before, body.anim-slow *::after {
|
||||
transition-duration: 400ms !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 {
|
||||
position: relative; padding: 14px; overflow: hidden;
|
||||
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
||||
}
|
||||
.theme-preview .tp-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px); }
|
||||
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
||||
.theme-preview .tp-card {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--t-surface);
|
||||
border: var(--tp-border-card, 1px) var(--tp-border-style, solid) var(--t-border);
|
||||
border-radius: var(--tp-radius-card, 0px);
|
||||
box-shadow: var(--tp-shadow-card, none);
|
||||
overflow: hidden;
|
||||
}
|
||||
.theme-preview .tp-card-bar { height: var(--tp-bar-height, 3px); background: var(--t-accent); }
|
||||
.theme-preview .tp-card-body { padding: var(--tp-pad-card, 14px); }
|
||||
.theme-preview .tp-headline { font-family: var(--tp-font-headline); color: var(--t-bright); font-size: 16px; font-weight: 700; margin-bottom: 4px; }
|
||||
.theme-preview .tp-content { font-family: var(--tp-font-content); color: var(--t-text); font-size: 11px; line-height: 1.55; margin-bottom: 8px; }
|
||||
.theme-preview .tp-meta { display: flex; gap: 6px; align-items: center; }
|
||||
.theme-preview .tp-tag {
|
||||
font-family: var(--tp-font-ui); font-size: 9px;
|
||||
color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--t-accent) 25%, transparent);
|
||||
border-radius: var(--tp-radius-tag, 2px); padding: 1px 6px;
|
||||
}
|
||||
.theme-preview .tp-ui { font-family: var(--tp-font-ui); font-size: 9px; color: var(--t-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.theme-preview .tp-side { display: flex; flex-direction: column; gap: 6px; min-width: 96px; }
|
||||
.theme-preview .tp-btn {
|
||||
font-family: var(--tp-font-ui); font-size: 10px;
|
||||
color: var(--t-accent); border: 1px solid var(--t-accent);
|
||||
padding: 4px 10px; border-radius: var(--tp-radius-button, 0px);
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
.theme-preview .tp-input {
|
||||
font-family: var(--tp-font-ui); font-size: 10px;
|
||||
color: var(--t-text); background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
padding: 4px 8px; border-radius: var(--tp-radius-input, 0px);
|
||||
outline: none; width: 100%;
|
||||
}
|
||||
|
||||
/* Range slider styling for design panel */
|
||||
.design-slider {
|
||||
flex: 1; height: 4px;
|
||||
-webkit-appearance: none; appearance: none;
|
||||
background: var(--t-border); border-radius: 2px; outline: none; cursor: pointer;
|
||||
}
|
||||
.design-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--t-accent); cursor: pointer; border: none;
|
||||
}
|
||||
.design-slider::-moz-range-thumb {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--t-accent); cursor: pointer; border: none;
|
||||
}
|
||||
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||||
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
||||
.design-row > select { width: 110px; }
|
||||
.design-row > .design-val { font-family: var(--t-font-pixel); font-size: 9px; color: var(--t-dim); width: 36px; text-align: right; flex-shrink: 0; }
|
||||
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
||||
.design-section-label:first-child { margin-top: 0; }
|
||||
#design-advanced > summary { list-style: none; }
|
||||
#design-advanced > summary::-webkit-details-marker { display: none; }
|
||||
#design-advanced > summary:hover { color: var(--t-bright); }
|
||||
#design-advanced .design-advanced-caret { transition: transform 0.15s; display: inline-block; }
|
||||
#design-advanced[open] .design-advanced-caret { transform: rotate(90deg); }
|
||||
|
||||
/* Settings peek: hide only the dim background so the user can see the real UI behind, while the settings popup stays open and interactive */
|
||||
body.settings-peeking #settings-overlay {
|
||||
background: transparent !important;
|
||||
pointer-events: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
||||
|
||||
/* Toolbar toggles */
|
||||
.toolbar-toggle {
|
||||
@@ -76,13 +289,61 @@ body::after {
|
||||
.line-clamp-5 { -webkit-line-clamp: 5; }
|
||||
|
||||
/* Superview */
|
||||
.superview-grid { column-gap: 12px; padding: 1rem 1.5rem 6rem; }
|
||||
.superview-item { padding: 3px 0; display: flex; align-items: center; gap: 6px; cursor: pointer; break-inside: avoid; transition: color 0.1s; }
|
||||
.superview-item:hover { color: var(--t-bright); }
|
||||
.superview-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.superview-title { font-size: 11px; color: var(--t-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* Single-column list view (toggled via #superview-toggle). Each row is
|
||||
a flex line with: color dot · optional icon · title (flex-grows) ·
|
||||
tag chips · bucket · date. Title ellipsizes when narrow. */
|
||||
.superview-grid { padding: 1rem 1.5rem 6rem; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.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-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); }
|
||||
|
||||
@@ -118,7 +379,8 @@ body::after {
|
||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
||||
|
||||
/* Cards */
|
||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
||||
.board-card > .card-bar { border-top-left-radius: var(--ui-radius-card); border-top-right-radius: var(--ui-radius-card); }
|
||||
.board-card { min-height: var(--card-min-h, 180px); }
|
||||
.card-layer { position: relative; z-index: 1; }
|
||||
.card-has-cover {
|
||||
@@ -144,27 +406,13 @@ body::after {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Group stacking effect */
|
||||
.card-group {
|
||||
position: relative;
|
||||
}
|
||||
.card-group::before,
|
||||
.card-group::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px; right: 4px;
|
||||
height: 100%;
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
}
|
||||
.card-group::before { top: 4px; opacity: 0.6; }
|
||||
.card-group::after { top: 8px; opacity: 0.3; left: 8px; right: 8px; }
|
||||
/* Group stacking effect — implemented via box-shadow on .board-card.card-group above
|
||||
(pseudo-elements would be clipped by the card's overflow:hidden). */
|
||||
.card-group { position: relative; }
|
||||
.card-group-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
@@ -212,8 +460,13 @@ body::after {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
z-index: 10;
|
||||
}
|
||||
.card-glow:hover { box-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-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 {
|
||||
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
||||
border-radius: 50%; background: var(--t-accent); z-index: 6;
|
||||
@@ -226,26 +479,38 @@ body::after {
|
||||
transform: rotate(45deg); margin-top: -2px;
|
||||
}
|
||||
.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 {
|
||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||
pointer-events: none; z-index: 10;
|
||||
}
|
||||
|
||||
/* Drop indicators — thick glowing bar */
|
||||
.card-drop-before::before,
|
||||
.card-drop-after::after {
|
||||
content: '';
|
||||
/* Card drop cursor — single absolutely-positioned vertical line that
|
||||
lives inside #board (which is `position: relative`) and gets
|
||||
repositioned by the dragover handlers in `board.js`. Replaces the
|
||||
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;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
background: var(--t-accent);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.card-drop-before::before { left: -6px; }
|
||||
.card-drop-after::after { right: -6px; }
|
||||
|
||||
/* Group fan preview */
|
||||
.group-fan {
|
||||
@@ -470,8 +735,149 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
}
|
||||
.bucket-add.drag-over i,
|
||||
.bucket-add.drag-over span { color: inherit !important; }
|
||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
||||
/* Bucket reorder drop cursor — a single absolutely-positioned vertical
|
||||
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-chip {
|
||||
@@ -506,6 +912,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
.search-hit { scroll-margin: 8px; }
|
||||
.search-hit.active { background: var(--t-raised); }
|
||||
.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 */
|
||||
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
||||
@@ -541,7 +948,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
|
||||
/* Prose (markdown) */
|
||||
.prose { line-height: 1.75; }
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.5em 0 0.6em; font-weight: 700; }
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-headline, var(--t-font-pixel, 'Silkscreen', cursive)); margin: 1.5em 0 0.6em; font-weight: 700; }
|
||||
.prose h1 { font-size: 1.8em; padding-bottom: 0.3em; border-bottom: 1px solid var(--t-border); }
|
||||
.prose h2 { font-size: 1.4em; }
|
||||
.prose h3 { font-size: 1.15em; }
|
||||
@@ -660,12 +1067,35 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--t-border);
|
||||
margin: 0.8em 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.prose img.resizing { outline: 2px solid var(--t-accent); }
|
||||
|
||||
/* Preview panes: ensure the prose content never overflows horizontally so
|
||||
images can resolve their `max-width: 100%` against a constrained container.
|
||||
Without this, images with explicit width attrs (e.g. <img width="2000">)
|
||||
push the content box wider than the flex pane. */
|
||||
#editor-preview,
|
||||
#board-preview-body {
|
||||
overflow-x: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
#preview-content,
|
||||
#board-preview-body > * {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
#editor-preview .prose img,
|
||||
#board-preview-body.prose img,
|
||||
#board-preview-body .prose img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||
|
||||
+616
-259
File diff suppressed because it is too large.
Load diff
+306
-70
@@ -6,13 +6,14 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
|
||||
import { state, getGroupCards } from './modules/state.js';
|
||||
import { showDialog, isDialogOpen } from './modules/dialog.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 { 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 { showContextMenu } from './modules/context-menu.js';
|
||||
import { undo, canUndo } from './modules/undo.js';
|
||||
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
|
||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||
@@ -38,7 +39,11 @@ window._ungroupCard = ungroupCard;
|
||||
window._getSelectedIds = getSelectedIds;
|
||||
window._clearSelection = clearSelection;
|
||||
window._refreshTasks = refreshTasksView;
|
||||
window.openFindBar = openFindBar;
|
||||
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) => {
|
||||
if (!text?.trim()) return;
|
||||
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-close')?.addEventListener('click', 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 ──
|
||||
|
||||
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
||||
@@ -126,10 +134,10 @@ importFile?.addEventListener('change', async e => {
|
||||
|
||||
// Check if it's a selective export (merge) or ask for full replace
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' });
|
||||
const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'Merge (add to existing)', cancelLabel: 'Replace (wipe all)' });
|
||||
// mode: true = merge, null = replace
|
||||
if (mode === null) {
|
||||
const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' });
|
||||
const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'Replace' });
|
||||
if (!sure) { e.target.value = ''; return; }
|
||||
}
|
||||
try {
|
||||
@@ -142,26 +150,10 @@ importFile?.addEventListener('change', async e => {
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
const importLogseq = document.getElementById('import-logseq-file');
|
||||
document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click());
|
||||
importLogseq?.addEventListener('change', async e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData(); form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('api/import-logseq', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
const data = JSON.parse(await res.text());
|
||||
if (data.error) throw new Error(data.error);
|
||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
||||
await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' });
|
||||
} catch {}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// ── Auth ──
|
||||
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true });
|
||||
const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'Logout', danger: true });
|
||||
if (!ok) return;
|
||||
await fetch('api/auth/logout', { method: 'POST' });
|
||||
location.reload();
|
||||
@@ -170,26 +162,34 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||
async function checkAuth() {
|
||||
const res = await fetch('api/auth/status');
|
||||
const data = await res.json();
|
||||
if (data.csrf) 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) {
|
||||
let pw = null;
|
||||
while (!pw || pw.length < 4) {
|
||||
pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '' });
|
||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||
if (pw && pw.length < 4) pw = null;
|
||||
}
|
||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
const d = await r.json();
|
||||
if (d.error) { location.reload(); return; }
|
||||
if (d.csrf) setCsrf(d.csrf);
|
||||
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||
} else if (!data.logged_in) {
|
||||
let ok = false;
|
||||
while (!ok) {
|
||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '' });
|
||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
||||
if (!pw) continue;
|
||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); }
|
||||
if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
|
||||
else { /* failed login: caller will retry */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,48 +198,178 @@ async function checkAuth() {
|
||||
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 modal = document.getElementById('modal');
|
||||
const detail = document.getElementById('detail');
|
||||
const search = document.getElementById('search');
|
||||
const tasks = document.getElementById('tasks');
|
||||
if (isDialogOpen()) return;
|
||||
const { board: gBoard } = guards;
|
||||
|
||||
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
|
||||
if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
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 = ''; }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
const qc = document.getElementById('quick-capture');
|
||||
if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; }
|
||||
if (detail.classList.contains('open')) 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(); }
|
||||
// — Escape chain (highest priority first) —
|
||||
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 = ''; } });
|
||||
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(); } });
|
||||
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
|
||||
registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
|
||||
registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
|
||||
registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() });
|
||||
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() });
|
||||
registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() });
|
||||
// Visible Escape entry for the help screen
|
||||
registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: '<kbd>Esc</kbd>', label: 'close any overlay' });
|
||||
|
||||
// Arrow keys navigate group in detail view
|
||||
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
|
||||
// — Global (work everywhere unless dialog is open) —
|
||||
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);
|
||||
if (!gc || gc.length < 2) return;
|
||||
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
|
||||
if (idx === -1) return;
|
||||
e.preventDefault();
|
||||
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 ──
|
||||
|
||||
@@ -251,17 +381,32 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
const title = e.target.value.trim();
|
||||
if (!title) return;
|
||||
const raw = e.target.value.trim();
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Split: first line = title, rest = body
|
||||
const lines = title.split(/\n/);
|
||||
const cardTitle = lines[0];
|
||||
let body = lines.slice(1).join('\n').trim();
|
||||
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
|
||||
// Split on first "|" — title before, body after.
|
||||
// Also accept newlines as a fallback (first line = title, rest = body).
|
||||
let cardTitle, body;
|
||||
const pipeIdx = raw.indexOf('|');
|
||||
if (pipeIdx >= 0) {
|
||||
cardTitle = raw.slice(0, pipeIdx).trim();
|
||||
body = raw.slice(pipeIdx + 1).trim();
|
||||
} else {
|
||||
const lines = raw.split(/\n/);
|
||||
cardTitle = lines[0].trim();
|
||||
body = lines.slice(1).join('\n').trim();
|
||||
}
|
||||
if (!cardTitle) return;
|
||||
// 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 = '';
|
||||
document.getElementById('quick-capture').classList.add('hidden');
|
||||
@@ -275,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);
|
||||
});
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
renderShortcutHelp();
|
||||
checkAuth();
|
||||
|
||||
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||
@@ -95,9 +95,12 @@
|
||||
getPostProcessors() {
|
||||
return postProcessors.slice();
|
||||
},
|
||||
// Filled in by core markdown.js after load
|
||||
render: null,
|
||||
};
|
||||
|
||||
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||
let csrfToken = '';
|
||||
const util = {
|
||||
$(idOrSel) {
|
||||
if (typeof idOrSel !== 'string') return null;
|
||||
@@ -114,6 +117,110 @@
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
},
|
||||
// CSRF: core calls setCsrf() after auth so plugins can read it.
|
||||
// Emits 'csrf.ready' so plugins that need to make state-changing
|
||||
// requests on init can defer until auth has happened.
|
||||
setCsrf(token) {
|
||||
csrfToken = String(token || '');
|
||||
if (csrfToken) hooks.emit('csrf.ready', csrfToken);
|
||||
},
|
||||
csrf() { return csrfToken; },
|
||||
};
|
||||
|
||||
/** Generic JSON API call with automatic CSRF header. */
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||
const res = await fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Settings registry ───────────────────────────────────────────
|
||||
// Plugins can inject their own tabs into the main settings overlay.
|
||||
// The plugin's onShow callback is called whenever the tab becomes visible.
|
||||
const settings = {
|
||||
registerTab({ id, label, icon, onShow }) {
|
||||
if (!id) return;
|
||||
const nav = document.getElementById('settings-nav');
|
||||
const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
|
||||
if (!nav || !panelsContainer) {
|
||||
// DOM not ready — defer to next tick
|
||||
requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
|
||||
return;
|
||||
}
|
||||
if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
|
||||
|
||||
// Create tab button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'settings-tab';
|
||||
btn.dataset.tab = id;
|
||||
const iconClass = icon || 'ph-puzzle-piece';
|
||||
btn.innerHTML = `<i class="ph ${iconClass} text-sm"></i><span class="font-pixel text-[10px]">${label || id}</span>`;
|
||||
nav.appendChild(btn);
|
||||
|
||||
// Create empty panel container
|
||||
let panel = document.getElementById(`settings-panel-${id}`);
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = `settings-panel-${id}`;
|
||||
panel.className = 'hidden';
|
||||
panelsContainer.appendChild(panel);
|
||||
}
|
||||
|
||||
// Click handler — duplicates core tab logic (theme.js may also
|
||||
// attach its own handler later, but identical work is harmless).
|
||||
btn.addEventListener('click', () => {
|
||||
nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
||||
panel.classList.remove('hidden');
|
||||
if (typeof onShow === 'function') {
|
||||
try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); }
|
||||
}
|
||||
hooks.emit('settings.tab.shown', { tab: id, panel });
|
||||
});
|
||||
},
|
||||
|
||||
/** Open the main settings overlay. */
|
||||
open() {
|
||||
document.getElementById('settings-btn')?.click();
|
||||
},
|
||||
|
||||
/** Open the settings overlay and switch to a specific tab. */
|
||||
openTab(id) {
|
||||
this.open();
|
||||
// Settings opens via click; the tab might need a frame to render
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Dialog API (filled in by core dialog.js after load) ─────────
|
||||
// Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...)
|
||||
// instead of native browser confirm()/prompt() so the UI stays themed
|
||||
// and consistent.
|
||||
const dialog = {
|
||||
show: null, // (opts) => Promise<value|null> — set by core dialog.js
|
||||
/** Convenience confirm wrapper. Returns Promise<true|null>. */
|
||||
async confirm(message, opts = {}) {
|
||||
if (typeof dialog.show !== 'function') {
|
||||
// Core dialog not loaded yet — fall back to native
|
||||
return window.confirm(message) ? true : null;
|
||||
}
|
||||
return dialog.show({
|
||||
title: opts.title || 'confirm',
|
||||
message,
|
||||
input: false,
|
||||
danger: opts.danger ?? true,
|
||||
okLabel: opts.okLabel || 'Ok',
|
||||
cancelLabel: opts.cancelLabel || 'Cancel',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Plugin metadata registry ────────────────────────────────────
|
||||
@@ -128,6 +235,9 @@
|
||||
editor,
|
||||
markdown,
|
||||
util,
|
||||
api,
|
||||
settings,
|
||||
dialog,
|
||||
plugins,
|
||||
registerPlugin,
|
||||
version: '1.0',
|
||||
|
||||
+223
-34
@@ -23,6 +23,68 @@ const boardPreviewTags = $('board-preview-tags');
|
||||
const boardPreviewMeta = $('board-preview-meta');
|
||||
let activeDragEl = 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 ──
|
||||
const selectedIds = state.selectedIds;
|
||||
@@ -60,6 +122,7 @@ function toggleSelect(id) {
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.clear();
|
||||
state.selectionWasBulk = false;
|
||||
board.querySelectorAll('.card-selected').forEach(el => {
|
||||
el.classList.remove('card-selected');
|
||||
el.querySelector('.card-select-check')?.remove();
|
||||
@@ -70,21 +133,9 @@ function clearSelection() {
|
||||
export function getSelectedIds() { return [...selectedIds]; }
|
||||
export { clearSelection };
|
||||
|
||||
// Delete/Backspace deletes selected cards
|
||||
document.addEventListener('keydown', async e => {
|
||||
if (!selectedIds.size) return;
|
||||
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: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
// Delete/Backspace for selected cards is now in the central shortcuts
|
||||
// registry (app.js registerShortcut). It uses getSelectedIds() /
|
||||
// clearSelection() which are already exported.
|
||||
|
||||
// Clear selection on click outside board (header, footer, etc)
|
||||
document.addEventListener('click', e => {
|
||||
@@ -111,6 +162,16 @@ $('select-group')?.addEventListener('click', async () => {
|
||||
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', () => {
|
||||
if (!selectedIds.size) return;
|
||||
// Remove existing picker
|
||||
@@ -176,7 +237,7 @@ $('select-bucket')?.addEventListener('click', () => {
|
||||
$('select-color')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'apply' });
|
||||
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'Apply' });
|
||||
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
|
||||
for (const id of selectedIds) {
|
||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
|
||||
@@ -188,7 +249,7 @@ $('select-color')?.addEventListener('click', async () => {
|
||||
$('select-tag')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'add' });
|
||||
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'Add' });
|
||||
if (!tag) return;
|
||||
const cleanTag = tag.trim().replace(/^#/, '');
|
||||
if (!cleanTag) return;
|
||||
@@ -215,7 +276,7 @@ $('select-delete')?.addEventListener('click', async () => {
|
||||
if (!selectedIds.size) return;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
||||
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) {
|
||||
@@ -295,8 +356,11 @@ function closeBoardPreview() {
|
||||
boardPreview?.classList.add('hidden');
|
||||
state.boardSplitOpen = false;
|
||||
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');
|
||||
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'));
|
||||
state.boardPreviewCardId = null;
|
||||
resetBoardPreview();
|
||||
@@ -418,7 +482,7 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
${loaded
|
||||
? (localStorage.getItem('hyprcrd-body-lines') === '0' ? '' : `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed flex-1" style="-webkit-line-clamp:var(--card-body-lines,5);display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;">${esc(stripMd(c.body))}</p>`)
|
||||
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
|
||||
${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `<div class="card-tags flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
@@ -430,6 +494,24 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
// ── Render ──
|
||||
|
||||
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;
|
||||
if (state.activeGroup) {
|
||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||
@@ -515,10 +597,9 @@ export function renderBoard() {
|
||||
const inGroup = !!state.activeGroup;
|
||||
|
||||
if (state.superView) {
|
||||
// Compact multi-column list view — override board grid
|
||||
// Single-column list view — override board grid
|
||||
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" style="column-count:${cols};">` + cards.map(c => {
|
||||
board.innerHTML = `<div class="superview-grid">` + cards.map(c => {
|
||||
const tags = parseTags(c);
|
||||
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
||||
let calCfg = null;
|
||||
@@ -526,10 +607,26 @@ export function renderBoard() {
|
||||
const dotColor = calCfg?.color || c.color;
|
||||
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
||||
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}">
|
||||
<span class="superview-dot" style="background:${dotColor}"></span>
|
||||
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
||||
<span class="superview-title">${esc(c.title)}</span>
|
||||
${tagsHtml}
|
||||
${bucketHtml}
|
||||
<span class="superview-date">${dateStr}</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
|
||||
@@ -614,6 +711,19 @@ export function renderBoard() {
|
||||
|
||||
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
|
||||
selectedIds.forEach(id => {
|
||||
const el = board.querySelector(`[data-id="${id}"]`);
|
||||
@@ -641,6 +751,23 @@ export function renderBoard() {
|
||||
|
||||
// Notification banner
|
||||
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 ──
|
||||
@@ -771,9 +898,10 @@ function renderGroupBar(cards) {
|
||||
if (state.activeGroup) {
|
||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||
bar.classList.remove('hidden');
|
||||
bar.dataset.groupId = String(state.activeGroup);
|
||||
bar.innerHTML = `
|
||||
<button id="group-back" 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-arrow-left text-sm"></i><span class="font-pixel text-[10px]">back</span>
|
||||
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Back</span>
|
||||
</button>
|
||||
<i class="ph ph-stack text-t-accent text-sm"></i>
|
||||
<span class="font-pixel text-[10px] text-t-accent">${esc(parent?.title || 'group')}</span>
|
||||
@@ -781,6 +909,7 @@ function renderGroupBar(cards) {
|
||||
$('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); });
|
||||
} else {
|
||||
bar.classList.add('hidden');
|
||||
delete bar.dataset.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,6 +970,36 @@ function hideFan() {
|
||||
// ── Card event binding ──
|
||||
|
||||
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 => {
|
||||
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
||||
});
|
||||
@@ -930,8 +1089,11 @@ function bindCardEvents() {
|
||||
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
||||
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 => {
|
||||
draggingCardIds.add(String(did));
|
||||
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||
});
|
||||
|
||||
@@ -977,20 +1139,36 @@ function bindCardEvents() {
|
||||
el.addEventListener('dragend', () => {
|
||||
// Remove ghost class from all dragged cards
|
||||
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
||||
draggingCardIds.clear();
|
||||
activeDragEl = null;
|
||||
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
||||
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
|
||||
x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
||||
board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group'));
|
||||
hideCardDropCursor();
|
||||
});
|
||||
el.addEventListener('dragover', e => {
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
const rect = el.getBoundingClientRect();
|
||||
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
||||
el.classList.toggle('card-drop-before', x < edge);
|
||||
el.classList.toggle('card-drop-after', x > rect.width - edge);
|
||||
el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
|
||||
// Three zones (matching the bucket fix): left 25% / right 25%
|
||||
// → insertion cursor in the gap, center 50% → group highlight
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
@@ -999,9 +1177,17 @@ function bindCardEvents() {
|
||||
|
||||
async function handleCardDrop(e, el, targetId) {
|
||||
e.preventDefault();
|
||||
const wasGroup = el.classList.contains('card-drop-group');
|
||||
const wasBefore = el.classList.contains('card-drop-before');
|
||||
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
|
||||
// Recompute the drop zone from the mouse position rather than reading
|
||||
// it back from CSS classes — the new cursor-based dragover doesn't
|
||||
// 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');
|
||||
if (!dragId || dragId === targetId) return;
|
||||
|
||||
@@ -1103,6 +1289,9 @@ board.addEventListener('mousedown', e => {
|
||||
if (!boxActive) {
|
||||
boxActive = true;
|
||||
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.className = 'box-select';
|
||||
board.appendChild(boxEl);
|
||||
|
||||
+385
-45
@@ -1,8 +1,9 @@
|
||||
// ── Bucket management ──
|
||||
|
||||
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 { pushUndo } from './undo.js';
|
||||
|
||||
// ── Bucket context menu ──
|
||||
|
||||
@@ -19,22 +20,38 @@ document.addEventListener('click', hideBucketCtx);
|
||||
|
||||
async function showBucketCtx(x, y, bucket) {
|
||||
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 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 isCollab = !!bucket.collab_uid;
|
||||
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="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="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>
|
||||
${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>
|
||||
<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 ${!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>
|
||||
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
||||
${hasCards
|
||||
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
|
||||
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>`
|
||||
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`
|
||||
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete (keep cards)</span></button>
|
||||
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete + cards</span></button>`
|
||||
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span></button>`
|
||||
}
|
||||
</div>
|
||||
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
|
||||
@@ -56,42 +73,96 @@ async function showBucketCtx(x, y, bucket) {
|
||||
document.getElementById('bctx-colors').classList.toggle('hidden');
|
||||
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') {
|
||||
hideBucketCtx();
|
||||
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
|
||||
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 loadBuckets(); window._renderBoard();
|
||||
}
|
||||
await window._reloadAll();
|
||||
}
|
||||
else if (a === 'move-left' || a === 'move-right') {
|
||||
hideBucketCtx();
|
||||
const swapIdx = a === 'move-left' ? idx - 1 : idx + 1;
|
||||
const other = state.buckets[swapIdx];
|
||||
const other = siblings[a === 'move-left' ? idx - 1 : idx + 1];
|
||||
if (!other) return;
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } });
|
||||
await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } });
|
||||
// Swap positions of two siblings — works for both top-level
|
||||
// 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();
|
||||
}
|
||||
else if (a === 'delete-keep') {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, 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 (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 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') {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, 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;
|
||||
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 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 => {
|
||||
e.stopPropagation();
|
||||
hideBucketCtx();
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
|
||||
await loadBuckets();
|
||||
window._renderBoard();
|
||||
const newColor = btn.dataset.bctxColor;
|
||||
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
|
||||
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 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() {
|
||||
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();
|
||||
populateSelects();
|
||||
}
|
||||
@@ -150,32 +242,235 @@ 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() {
|
||||
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`;
|
||||
|
||||
// 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 = `
|
||||
<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 === '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 === '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 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>`;
|
||||
}).join('')}
|
||||
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
|
||||
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">Bucket</span></button>`;
|
||||
|
||||
updateHeaderTint();
|
||||
|
||||
let draggingBucketId = null;
|
||||
|
||||
bar.querySelectorAll('.bucket-tab').forEach(t => {
|
||||
const bid = t.dataset.bucket;
|
||||
const isUser = bid !== 'all' && bid !== 'none';
|
||||
// Single insertion-cursor element shared by all bucket tabs. Lives
|
||||
// inside #bucket-bar (which is `position: relative` so absolute
|
||||
// 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) {
|
||||
t.addEventListener('contextmenu', e => {
|
||||
@@ -194,18 +489,31 @@ function renderBar() {
|
||||
t.addEventListener('dragend', () => {
|
||||
t.style.opacity = '';
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
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 left = e.clientX < rect.left + rect.width / 2;
|
||||
t.classList.toggle('bucket-drop-left', left);
|
||||
t.classList.toggle('bucket-drop-right', !left);
|
||||
const x = e.clientX - rect.left;
|
||||
const isLeft = x < rect.width * 0.25;
|
||||
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');
|
||||
} else if (!draggingBucketId) {
|
||||
// Card drop on bucket
|
||||
@@ -213,23 +521,44 @@ function renderBar() {
|
||||
}
|
||||
});
|
||||
t.addEventListener('dragleave', () => {
|
||||
t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
|
||||
t.classList.remove('drag-over');
|
||||
});
|
||||
t.addEventListener('drop', async e => {
|
||||
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');
|
||||
if (dragBid && isUser && dragBid !== bid) {
|
||||
const rect = t.getBoundingClientRect();
|
||||
const before = e.clientX < rect.left + rect.width / 2;
|
||||
const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid);
|
||||
const targetIdx = ids.indexOf(String(bid));
|
||||
ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid);
|
||||
await Promise.all(ids.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } })));
|
||||
const x = e.clientX - rect.left;
|
||||
const isLeft = x < rect.width * 0.25;
|
||||
const isRight = x > rect.width * 0.75;
|
||||
const isCenter = !isLeft && !isRight;
|
||||
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();
|
||||
window._renderBoard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -246,7 +575,10 @@ function renderBar() {
|
||||
window._renderBoard();
|
||||
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;
|
||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
||||
window._clearSelection?.();
|
||||
@@ -256,6 +588,14 @@ function renderBar() {
|
||||
|
||||
const addBtn = $('add-bucket-btn');
|
||||
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...' });
|
||||
if (!name || !name.trim()) return;
|
||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||
|
||||
+71
-17
@@ -120,9 +120,31 @@ export async function loadSettings() {
|
||||
}
|
||||
|
||||
|
||||
function setCaldavTestStatus(text, kind = 'info') {
|
||||
if (!testStatus) return;
|
||||
if (!text) {
|
||||
testStatus.classList.add('hidden');
|
||||
testStatus.textContent = '';
|
||||
testStatus.style.borderColor = '';
|
||||
testStatus.style.color = '';
|
||||
return;
|
||||
}
|
||||
testStatus.classList.remove('hidden');
|
||||
testStatus.textContent = text;
|
||||
if (kind === 'error') {
|
||||
testStatus.style.borderColor = 'var(--t-danger)';
|
||||
testStatus.style.color = 'var(--t-danger)';
|
||||
} else if (kind === 'success') {
|
||||
testStatus.style.borderColor = 'var(--t-accent)';
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
} else {
|
||||
testStatus.style.borderColor = 'var(--t-border)';
|
||||
testStatus.style.color = 'var(--t-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
$('caldav-test')?.addEventListener('click', async () => {
|
||||
testStatus.textContent = 'testing...';
|
||||
testStatus.style.color = 'var(--t-dim)';
|
||||
setCaldavTestStatus('testing...');
|
||||
const body = { url: urlInput.value, username: userInput.value };
|
||||
if (passInput.value) body.password = passInput.value;
|
||||
const res = await fetch('api/caldav/test', {
|
||||
@@ -132,12 +154,10 @@ $('caldav-test')?.addEventListener('click', async () => {
|
||||
|
||||
if (res.ok) {
|
||||
const names = (res.calendars || []).map(c => c.name).join(', ');
|
||||
testStatus.textContent = `connected (${names || 'no calendars'})`;
|
||||
testStatus.style.color = 'var(--t-accent)';
|
||||
setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success');
|
||||
if (res.calendars) renderCalList(res.calendars);
|
||||
} else {
|
||||
testStatus.textContent = res.error || 'failed';
|
||||
testStatus.style.color = 'var(--t-danger)';
|
||||
setCaldavTestStatus(res.error || 'failed', 'error');
|
||||
}
|
||||
showConnectionLog(res.log);
|
||||
});
|
||||
@@ -155,6 +175,8 @@ $('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||
export async function triggerSync() {
|
||||
const btn = $('cal-sync');
|
||||
if (btn) btn.style.opacity = '0.5';
|
||||
const headerIcon = $('header-sync-icon');
|
||||
if (headerIcon) headerIcon.classList.add('card-spinner');
|
||||
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||
|
||||
const res = await fetch('api/caldav/sync', {
|
||||
@@ -164,6 +186,7 @@ export async function triggerSync() {
|
||||
if (btn) btn.style.opacity = '';
|
||||
|
||||
// Also poll relay
|
||||
// (header spinner stays on until the relay poll completes too — see end)
|
||||
const relayRes = await fetch('api/relay/poll', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
@@ -182,6 +205,7 @@ export async function triggerSync() {
|
||||
} else {
|
||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
||||
}
|
||||
|
||||
function showLog(log) {
|
||||
@@ -202,24 +226,48 @@ function showConnectionLog(lines) {
|
||||
|
||||
// ── Relay settings ──
|
||||
|
||||
function setRelayStatus(text, kind = 'info') {
|
||||
const el = $('relay-status');
|
||||
if (!el) return;
|
||||
if (!text) {
|
||||
el.classList.add('hidden');
|
||||
el.textContent = '';
|
||||
el.style.borderColor = '';
|
||||
el.style.color = '';
|
||||
return;
|
||||
}
|
||||
el.classList.remove('hidden');
|
||||
el.textContent = text;
|
||||
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
||||
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
||||
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
||||
}
|
||||
|
||||
$('relay-save')?.addEventListener('click', async () => {
|
||||
const relayStatus = $('relay-status');
|
||||
const body = { url: $('relay-url')?.value || '' };
|
||||
const key = $('relay-key')?.value;
|
||||
if (key) body.key = key;
|
||||
const body = {
|
||||
url: $('relay-url')?.value || '',
|
||||
username: $('relay-username')?.value || '',
|
||||
key: $('relay-key')?.value || '',
|
||||
};
|
||||
setRelayStatus('saving...');
|
||||
await api('api/relay/settings', { method: 'POST', body });
|
||||
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
|
||||
setRelayStatus('saved', 'success');
|
||||
setTimeout(() => setRelayStatus(''), 1500);
|
||||
});
|
||||
|
||||
$('relay-test')?.addEventListener('click', async () => {
|
||||
const relayStatus = $('relay-status');
|
||||
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
|
||||
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
|
||||
setRelayStatus('testing...');
|
||||
const res = await fetch('api/relay/poll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ strict: true }),
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
if (res?.ok) {
|
||||
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
|
||||
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
||||
if (res.pulled) await window._reloadAll?.();
|
||||
} else {
|
||||
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
|
||||
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
||||
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -227,13 +275,19 @@ export async function loadRelaySettings() {
|
||||
const s = await api('api/relay/settings');
|
||||
if (!s) return;
|
||||
const urlInput = $('relay-url');
|
||||
const userInput = $('relay-username');
|
||||
const keyInput = $('relay-key');
|
||||
if (urlInput) urlInput.value = s.url || '';
|
||||
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
|
||||
if (userInput) userInput.value = s.username || '';
|
||||
if (keyInput) {
|
||||
keyInput.value = s.key || '';
|
||||
keyInput.placeholder = 'shared secret';
|
||||
}
|
||||
}
|
||||
|
||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||
$('header-sync')?.addEventListener('click', () => triggerSync());
|
||||
|
||||
// Auto-sync throttle
|
||||
let lastAutoSync = 0;
|
||||
|
||||
@@ -398,7 +398,7 @@ function openDayPopup(ds, cellEl) {
|
||||
</div>`;
|
||||
}).join('');
|
||||
} 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);
|
||||
|
||||
@@ -506,6 +506,9 @@ export function openCalendar() {
|
||||
export function closeCalendar() {
|
||||
closeDayPopup();
|
||||
overlay.classList.remove('open');
|
||||
if (document.activeElement && document.activeElement !== document.body) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
$('calendar-btn')?.addEventListener('click', openCalendar);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { $, esc, api } from './util.js';
|
||||
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.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 items = $('ctx-items');
|
||||
@@ -11,6 +12,12 @@ const colors = $('ctx-colors');
|
||||
const buckets = $('ctx-buckets');
|
||||
const members = $('ctx-members');
|
||||
const tags = $('ctx-tags');
|
||||
const relayTenants = $('ctx-relay-tenants');
|
||||
|
||||
// Cached relay tenant list. Loaded on first push-relay submenu open
|
||||
// for the session, then reused. Refreshed if the user closes/reopens
|
||||
// the menu without success.
|
||||
let cachedRelayTenants = null;
|
||||
|
||||
export function hideContextMenu() { menu.classList.add('hidden'); }
|
||||
|
||||
@@ -25,34 +32,127 @@ menu.addEventListener('mouseenter', () => {
|
||||
document.addEventListener('click', 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) {
|
||||
const children = getChildren(card.id);
|
||||
const isParent = children.length > 0;
|
||||
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 = `
|
||||
<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="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>` : ''}
|
||||
<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="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="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</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>
|
||||
${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="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>
|
||||
${/^\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-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="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="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>
|
||||
<button class="ctx-item" data-action="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-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="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-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">Tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
${isParent ? `<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">open group</span></button>
|
||||
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">dissolve group</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Open group</span></button>
|
||||
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">Members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Dissolve group</span></button>` : ''}
|
||||
${isChild ? `<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">go to group</span></button>
|
||||
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">ungroup</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Go to group</span></button>
|
||||
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Ungroup</span></button>` : ''}
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`;
|
||||
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span></button>`;
|
||||
|
||||
// Color sub-panel
|
||||
const grid = $('ctx-color-grid');
|
||||
@@ -71,7 +171,7 @@ export function showContextMenu(x, y, card) {
|
||||
members.classList.add('hidden');
|
||||
|
||||
// Bucket sub-panel
|
||||
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">no bucket</span></button>` +
|
||||
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">No bucket</span></button>` +
|
||||
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).join('');
|
||||
buckets.classList.add('hidden');
|
||||
|
||||
@@ -91,7 +191,7 @@ export function showContextMenu(x, y, card) {
|
||||
<span class="font-pixel text-[10px]">#${esc(t)}</span>
|
||||
</button>`).join('') +
|
||||
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">add #${esc(f)}</span>
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">Add #${esc(f)}</span>
|
||||
</button>` : '');
|
||||
|
||||
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
@@ -162,12 +262,17 @@ export function showContextMenu(x, y, card) {
|
||||
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
|
||||
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
|
||||
|
||||
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
|
||||
// Actions
|
||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const a = btn.dataset.action;
|
||||
if (a === 'join') {
|
||||
hideContextMenu();
|
||||
await joinSelectedCards();
|
||||
return;
|
||||
}
|
||||
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
||||
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
||||
else if (a === 'duplicate') {
|
||||
@@ -175,19 +280,21 @@ export function showContextMenu(x, y, card) {
|
||||
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
|
||||
if (hasDate) {
|
||||
const curDate = card.title.substring(0, 10);
|
||||
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;
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
else if (a === 'move-date') {
|
||||
hideContextMenu();
|
||||
const curDate = card.title.substring(0, 10);
|
||||
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'move' });
|
||||
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'Move' });
|
||||
if (!newDate || newDate === curDate) return;
|
||||
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
|
||||
@@ -196,29 +303,44 @@ export function showContextMenu(x, y, card) {
|
||||
}
|
||||
else if (a === 'due-done') {
|
||||
hideContextMenu();
|
||||
// Clear due date and mark as done
|
||||
const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
|
||||
const isDone = parseTags(card).includes('done');
|
||||
let newBody;
|
||||
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();
|
||||
}
|
||||
else if (a === 'due-date') {
|
||||
hideContextMenu();
|
||||
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'update' : 'set' });
|
||||
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'Update' : 'Set' });
|
||||
if (newDate === null) return;
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
|
||||
await window._reloadAll();
|
||||
}
|
||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||
else if (a === 'push-relay') {
|
||||
else if (a === 'make-ticket') {
|
||||
hideContextMenu();
|
||||
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
|
||||
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
|
||||
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
||||
const n = document.createElement('div');
|
||||
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
|
||||
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
|
||||
document.body.appendChild(n);
|
||||
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
|
||||
// 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') {
|
||||
await openRelayTenantSubmenu(card);
|
||||
}
|
||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||
else if (a === 'color') toggle(colors);
|
||||
@@ -228,19 +350,57 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
|
||||
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
|
||||
else if (a === 'dissolve') {
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen?`, danger: true, okLabel: 'dissolve' });
|
||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'Dissolve' });
|
||||
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.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') {
|
||||
hideContextMenu();
|
||||
const children = getChildren(card.id);
|
||||
const isGroup = children.length > 0;
|
||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||
if (needConfirm) {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
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' });
|
||||
if (!ok) return;
|
||||
}
|
||||
const wasSync = !!card.caldav_uid;
|
||||
pushCardDelete('delete', snapshotCard(card));
|
||||
// Snapshot everything for undo BEFORE deleting
|
||||
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 window._reloadAll();
|
||||
if (wasSync) window._triggerSync?.();
|
||||
@@ -252,3 +412,87 @@ export function showContextMenu(x, y, card) {
|
||||
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
||||
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
||||
}
|
||||
|
||||
// ── Push to relay submenu (cross-tenant recipient picker) ──
|
||||
|
||||
async function openRelayTenantSubmenu(card) {
|
||||
// Show submenu immediately with a loading state, then fetch the
|
||||
// tenant list (cached after first successful load).
|
||||
const toggle = (panel) => {
|
||||
[colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); });
|
||||
panel.classList.remove('hidden');
|
||||
};
|
||||
toggle(relayTenants);
|
||||
|
||||
if (!cachedRelayTenants) {
|
||||
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel">loading tenants...</div>';
|
||||
try {
|
||||
const res = await fetch('api/relay/tenants').then(r => r.json());
|
||||
if (!res.ok) {
|
||||
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${res.error || 'failed'}</div>`;
|
||||
return;
|
||||
}
|
||||
cachedRelayTenants = res;
|
||||
} catch (err) {
|
||||
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${err.message}</div>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderRelayTenants(card);
|
||||
}
|
||||
|
||||
function renderRelayTenants(card) {
|
||||
const list = cachedRelayTenants?.tenants || [];
|
||||
const self = cachedRelayTenants?.self || '';
|
||||
if (!list.length) {
|
||||
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic cursor-blink">no tenants</div>';
|
||||
return;
|
||||
}
|
||||
// Self first, then others alphabetical
|
||||
const ordered = [
|
||||
...list.filter(t => t.username === self),
|
||||
...list.filter(t => t.username !== self),
|
||||
];
|
||||
relayTenants.innerHTML = ordered.map(t => {
|
||||
const isSelf = t.username === self;
|
||||
const icon = isSelf ? 'ph-user' : 'ph-user-circle';
|
||||
const label = isSelf ? `${t.username} (you)` : t.username;
|
||||
return `<button class="ctx-item" data-relay-recipient="${t.username}"><i class="ph ${icon} text-sm"></i><span class="font-pixel text-[10px]">${label}</span></button>`;
|
||||
}).join('');
|
||||
|
||||
relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const recipient = btn.dataset.relayRecipient;
|
||||
hideContextMenu();
|
||||
const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
|
||||
relayPushToast(res, recipient);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function relayPushToast(res, recipient) {
|
||||
const ok = res?.ok;
|
||||
const msg = ok
|
||||
? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
|
||||
: (res?.error || 'push failed');
|
||||
const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
||||
if (!ok) {
|
||||
// Dump every diagnostic the backend gave us so we can see the wire-level
|
||||
// truth instead of guessing why the relay said no
|
||||
console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
|
||||
console.log('error:', res?.error);
|
||||
if (res?.relay_url) console.log('url:', res.relay_url);
|
||||
if (res?.sent_username) console.log('sent username:', res.sent_username);
|
||||
if (res?.response_body) console.log('response body:', res.response_body);
|
||||
if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
|
||||
console.groupEnd();
|
||||
}
|
||||
const n = document.createElement('div');
|
||||
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
|
||||
const hint = ok ? '' : '<div style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);margin-top:4px;">see browser console for wire-level debug</div>';
|
||||
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${esc(msg)}</div>${hint}`;
|
||||
document.body.appendChild(n);
|
||||
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
|
||||
}
|
||||
@@ -391,6 +391,11 @@ export function closeDetail(options = {}) {
|
||||
state._returnToCalendar = false;
|
||||
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) {
|
||||
@@ -417,6 +422,7 @@ function renderSidebar(card) {
|
||||
|
||||
if (groupCards && groupCards.length > 1) {
|
||||
sidebar.classList.remove('hidden');
|
||||
sidebar.dataset.groupId = String(groupCards[0].id);
|
||||
$('detail-sidebar-count').textContent = groupCards.length;
|
||||
list.innerHTML = groupCards.map(gc => {
|
||||
const active = gc.id == card.id;
|
||||
@@ -438,6 +444,7 @@ function renderSidebar(card) {
|
||||
});
|
||||
} else {
|
||||
sidebar.classList.add('hidden');
|
||||
delete sidebar.dataset.groupId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +458,7 @@ function updateDoneButton(card) {
|
||||
btn.classList.toggle('text-t-accent', isDone);
|
||||
btn.classList.toggle('border-t-accent', isDone);
|
||||
}
|
||||
if (label) label.textContent = isDone ? 'undone' : 'done';
|
||||
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
||||
}
|
||||
|
||||
function showContent(card) {
|
||||
@@ -805,7 +812,7 @@ function renderSplitGroup(cards, label) {
|
||||
|
||||
function renderSplitPicks(cards, label) {
|
||||
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;
|
||||
}
|
||||
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
||||
@@ -817,7 +824,7 @@ function renderSplitPicks(cards, label) {
|
||||
function renderSplitGroups(groups) {
|
||||
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
||||
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;
|
||||
}
|
||||
splitPicks.innerHTML = html;
|
||||
@@ -892,7 +899,7 @@ function loadSplitCard(id) {
|
||||
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
|
||||
</button>
|
||||
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" title="open as main card">
|
||||
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">focus</span>
|
||||
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span>
|
||||
</button>
|
||||
</div>`;
|
||||
splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList);
|
||||
@@ -1015,7 +1022,7 @@ $('detail-done')?.addEventListener('click', async () => {
|
||||
$('detail-delete')?.addEventListener('click', async () => {
|
||||
if (!state.viewingCard) return;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${state.viewingCard.title}"?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||
|
||||
@@ -110,7 +110,7 @@ function renderDatepicker() {
|
||||
});
|
||||
}
|
||||
|
||||
export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
|
||||
export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'Ok', cancelLabel = 'Cancel' }) {
|
||||
return new Promise(res => {
|
||||
resolve = res;
|
||||
titleEl.textContent = title;
|
||||
@@ -150,6 +150,11 @@ export function showDialog({ title, message = '', input = false, placeholder = '
|
||||
|
||||
export function isDialogOpen() { return overlay.classList.contains('open'); }
|
||||
|
||||
// Expose to plugins via the hyprcrd API
|
||||
if (typeof window !== 'undefined' && window.hyprcrd?.dialog) {
|
||||
window.hyprcrd.dialog.show = showDialog;
|
||||
}
|
||||
|
||||
okBtn.addEventListener('click', submit);
|
||||
cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||
if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||
|
||||
+152
-10
@@ -116,7 +116,7 @@ const inpDueBtn = $('inp-due-date');
|
||||
|
||||
function setDueDate(date) {
|
||||
if (inpDueValue) inpDueValue.value = date || '';
|
||||
if (inpDueLabel) inpDueLabel.textContent = date || 'no due';
|
||||
if (inpDueLabel) inpDueLabel.textContent = date || 'No due';
|
||||
if (inpDueBtn) {
|
||||
inpDueBtn.classList.toggle('text-t-accent', !!date);
|
||||
inpDueBtn.classList.toggle('border-t-accent', !!date);
|
||||
@@ -131,7 +131,7 @@ inpDueBtn?.addEventListener('click', async () => {
|
||||
title: current ? 'change due date' : 'set due date',
|
||||
input: 'date',
|
||||
defaultValue: current || new Date().toISOString().substring(0, 10),
|
||||
okLabel: current ? 'update' : 'set',
|
||||
okLabel: current ? 'Update' : 'Set',
|
||||
});
|
||||
if (result === null) return;
|
||||
setDueDate(result || '');
|
||||
@@ -147,7 +147,7 @@ function updateDoneToggle() {
|
||||
inpDoneToggle.classList.toggle('border-t-accent', isDone);
|
||||
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
|
||||
}
|
||||
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'undone' : 'done';
|
||||
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'Undone' : 'Done';
|
||||
}
|
||||
|
||||
inpDoneToggle?.addEventListener('click', () => {
|
||||
@@ -200,6 +200,8 @@ export function openModal(card = null) {
|
||||
currentLine = -1;
|
||||
updateActiveLine();
|
||||
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);
|
||||
@@ -354,11 +356,13 @@ body.addEventListener('keyup', updateActiveLine);
|
||||
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
||||
|
||||
export function closeModal({ animate = true } = {}) {
|
||||
const closingId = state.editingId;
|
||||
if (animate) startCloseMorph();
|
||||
else clearMorphGhost();
|
||||
hideLinkAc();
|
||||
hideTagAc();
|
||||
hideSnippetAc();
|
||||
closeFindBar();
|
||||
modal.classList.remove('open');
|
||||
state.editingId = null;
|
||||
state.returnToGalleryAfterEdit = false;
|
||||
@@ -366,6 +370,12 @@ export function closeModal({ animate = true } = {}) {
|
||||
state._returnToCalendar = false;
|
||||
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() {
|
||||
@@ -406,6 +416,7 @@ async function doSave(close, { animate = true } = {}) {
|
||||
if (created?.id) {
|
||||
if (!close) state.editingId = created.id;
|
||||
state._lastSavedId = created.id;
|
||||
state.recentlyCreatedIds?.add(String(created.id));
|
||||
}
|
||||
}
|
||||
await window._reloadAll();
|
||||
@@ -517,8 +528,11 @@ const editorPane = $('editor-pane');
|
||||
|
||||
function applyEditorSplit() {
|
||||
if (!state.previewVisible) return;
|
||||
if (editorPane) editorPane.style.flex = `0 0 ${editorSplit}%`;
|
||||
preview.style.flex = `0 0 ${100 - editorSplit}%`;
|
||||
// Use grow-based splits with flex-basis:0 so both panes share the available
|
||||
// space AFTER the divider is subtracted. Using `0 0 X%` overflows the container
|
||||
// because X% + (100-X)% + divider > 100%, which clips content on the right.
|
||||
if (editorPane) editorPane.style.flex = `${editorSplit} 1 0`;
|
||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||
}
|
||||
|
||||
$('toggle-preview')?.addEventListener('click', () => {
|
||||
@@ -976,15 +990,133 @@ body.addEventListener('keydown', e => {
|
||||
|
||||
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) ──
|
||||
|
||||
async function uploadFile(file) {
|
||||
const isAudio = file.type.startsWith('audio/');
|
||||
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 placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
|
||||
const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
|
||||
const pos = body.selectionStart;
|
||||
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
@@ -997,7 +1129,9 @@ async function uploadFile(file) {
|
||||
const data = JSON.parse(text);
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (data.url) {
|
||||
const md = isAudio
|
||||
const md = isPdf
|
||||
? `[📄 ${file.name || data.name}](${data.url})`
|
||||
: isAudio
|
||||
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
||||
: ``;
|
||||
const idx = body.value.indexOf(placeholder);
|
||||
@@ -1020,7 +1154,7 @@ body.addEventListener('paste', e => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
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();
|
||||
const file = item.getAsFile();
|
||||
if (file) uploadFile(file);
|
||||
@@ -1034,7 +1168,7 @@ body.addEventListener('drop', e => {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files?.length) return;
|
||||
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();
|
||||
uploadFile(file);
|
||||
return;
|
||||
@@ -1042,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 ──
|
||||
|
||||
document.addEventListener('mousedown', e => {
|
||||
|
||||
+168
-21
@@ -1,39 +1,79 @@
|
||||
// ── Image gallery ──
|
||||
// ── Assets (images, audio, PDFs) ──
|
||||
|
||||
import { $, esc, api, getCsrf } from './util.js';
|
||||
|
||||
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 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">
|
||||
<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>
|
||||
</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">
|
||||
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let activeFilter = 'all';
|
||||
import { state } from './state.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() {
|
||||
const urls = new Set();
|
||||
for (const card of state.cards) {
|
||||
for (const card of galleryCards) {
|
||||
const body = card.body || '';
|
||||
let m;
|
||||
// Markdown images: 
|
||||
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
||||
// HTML images
|
||||
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
||||
// Slide-image blocks
|
||||
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
||||
while ((m = slideRe.exec(body)) !== null) {
|
||||
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||
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;
|
||||
}
|
||||
|
||||
function findCardForUrl(url) {
|
||||
for (const card of state.cards) {
|
||||
for (const card of galleryCards) {
|
||||
const body = card.body || '';
|
||||
if (body.includes(url)) return card;
|
||||
}
|
||||
@@ -44,7 +84,19 @@ export async function openGallery() {
|
||||
const el = $('gallery');
|
||||
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 grid = $('gallery-grid');
|
||||
const count = $('gallery-count');
|
||||
@@ -61,23 +113,65 @@ export async function openGallery() {
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
<p class="font-pixel text-[10px] text-t-dim">no images</p></div>`;
|
||||
<i class="ph ph-folder-open text-4xl text-t-dim mb-2 block"></i>
|
||||
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no assets</p></div>`;
|
||||
} else {
|
||||
let html = '';
|
||||
|
||||
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 => `
|
||||
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}">
|
||||
<div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
|
||||
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">
|
||||
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}" data-url="${esc(f.url)}" data-section="orphaned">
|
||||
<div class="relative">
|
||||
${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">
|
||||
<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-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,9 +180,9 @@ export async function openGallery() {
|
||||
}
|
||||
|
||||
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 => `
|
||||
<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')}
|
||||
<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">
|
||||
@@ -111,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 => {
|
||||
item.addEventListener('click', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
const name = item.dataset.orphan;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,7 +225,23 @@ export async function openGallery() {
|
||||
const name = btn.dataset.newOrphan;
|
||||
const label = name.replace(/\.[^.]+$/, '');
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// Orphan: inline new slide button (creates image slide with slide-image block)
|
||||
grid.querySelectorAll('[data-new-slide]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.newSlide;
|
||||
const label = name.replace(/\.[^.]+$/, '');
|
||||
closeGallery();
|
||||
const body = '```slide-image\nsrc: uploads/' + name + '\n```';
|
||||
window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +251,7 @@ export async function openGallery() {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.delOrphan;
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_image', message: `Delete "${name}"?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
@@ -147,6 +260,9 @@ export async function openGallery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the active filter (persists across re-opens within a session)
|
||||
applyFilter(activeFilter);
|
||||
|
||||
el.classList.add('open');
|
||||
}
|
||||
|
||||
@@ -161,10 +277,13 @@ function openOrphanLightbox(file) {
|
||||
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
||||
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">New card</span>
|
||||
</button>
|
||||
<button class="orphan-lb-slide flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">New slide</span>
|
||||
</button>
|
||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
||||
@@ -183,8 +302,16 @@ function openOrphanLightbox(file) {
|
||||
window._openModal({ id: null, title: name, body: ``, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
|
||||
overlay.querySelector('.orphan-lb-slide').addEventListener('click', async () => {
|
||||
const name = file.url.split('/').pop().replace(/\.[^.]+$/, '');
|
||||
close();
|
||||
closeGallery();
|
||||
const body = '```slide-image\nsrc: ' + file.url + '\n```';
|
||||
window._openModal({ id: null, title: name, body, color: '#38bdf8', bucket_id: null });
|
||||
});
|
||||
|
||||
overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
|
||||
const ok = await showDialog({ title: 'delete_image', message: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
||||
const ok = await showDialog({ title: 'delete_image', message: `Delete "${file.name}"?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
close();
|
||||
@@ -201,3 +328,23 @@ export function closeGallery() {
|
||||
$('gallery-close')?.addEventListener('click', closeGallery);
|
||||
$('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); });
|
||||
$('gallery-btn')?.addEventListener('click', openGallery);
|
||||
|
||||
// Upload button
|
||||
$('gallery-upload')?.addEventListener('click', () => $('gallery-upload-file')?.click());
|
||||
$('gallery-upload-file')?.addEventListener('change', async e => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
for (const file of files) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
try {
|
||||
await fetch('api/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-CSRF-Token': getCsrf() },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
e.target.value = '';
|
||||
await openGallery();
|
||||
});
|
||||
@@ -168,6 +168,11 @@ export function renderMd(src) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Expose to plugins via the hyprcrd API
|
||||
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||
window.hyprcrd.markdown.render = renderMd;
|
||||
}
|
||||
|
||||
export function addCodeCopyButtons(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('pre > code').forEach(code => {
|
||||
@@ -238,12 +243,23 @@ export function getTodos(src) {
|
||||
return { total, done: (s.match(/- \[x\]/g) || []).length };
|
||||
}
|
||||
|
||||
// Matches the `src:` line inside a ```slide-image fenced block (slideshow plugin format).
|
||||
const SLIDE_IMAGE_RE = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/;
|
||||
function slideImageSrc(block) {
|
||||
if (!block) return null;
|
||||
const m = block.match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function firstImage(src) {
|
||||
const s = src || '';
|
||||
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
||||
if (md) return md[1];
|
||||
const html = s.match(/<img[^>]+src="([^"]+)"/i);
|
||||
return html ? html[1] : null;
|
||||
if (html) return html[1];
|
||||
const slide = s.match(SLIDE_IMAGE_RE);
|
||||
if (slide) return slideImageSrc(slide[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function leadingImage(src) {
|
||||
@@ -252,5 +268,9 @@ export function leadingImage(src) {
|
||||
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
|
||||
if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null;
|
||||
const html = s.match(/^<img[^>]+src=(['"])(.*?)\1/i);
|
||||
return html ? html[2] : null;
|
||||
if (html) return html[2];
|
||||
// A card whose body *starts* with a slide-image block → use its image as the cover.
|
||||
const slide = s.match(/^```slide-image\s*\n([\s\S]*?)(?:\n```|$)/);
|
||||
if (slide) return slideImageSrc(slide[1]);
|
||||
return null;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ function renderStream() {
|
||||
const all = getAllItems();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ function render(list) {
|
||||
});
|
||||
}
|
||||
|
||||
let reloadTimer = null;
|
||||
|
||||
async function onToggle(e) {
|
||||
const input = e.currentTarget;
|
||||
const name = input.dataset.name;
|
||||
@@ -58,6 +60,10 @@ async function onToggle(e) {
|
||||
pluginsCache = res.plugins;
|
||||
dirtyAfterToggle = true;
|
||||
$('plugins-reload-notice')?.classList.remove('hidden');
|
||||
// Auto-reload so the effect of the toggle is visible immediately.
|
||||
// Debounced so rapid successive toggles only trigger one reload.
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(() => location.reload(), 400);
|
||||
} catch (err) {
|
||||
// Revert UI on failure
|
||||
input.checked = !enabled;
|
||||
|
||||
+99
-12
@@ -3,6 +3,7 @@
|
||||
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||
import { renderMd, stripMd } from './markdown.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
|
||||
const overlay = $('search');
|
||||
const input = $('search-input');
|
||||
@@ -11,29 +12,74 @@ const footer = $('search-footer');
|
||||
const previewEmpty = $('search-preview-empty');
|
||||
const previewContent = $('search-preview-content');
|
||||
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?.();
|
||||
input.value = '';
|
||||
results.innerHTML = '';
|
||||
footer.classList.add('hidden');
|
||||
active = -1;
|
||||
overlay.classList.add('open');
|
||||
setReplaceMode(replace);
|
||||
input.focus();
|
||||
clearPreview();
|
||||
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 getPool() {
|
||||
const cards = (replaceMode && freshCards) ? freshCards : state.cards;
|
||||
const bv = bucketFilter.value;
|
||||
if (!bv) return state.cards;
|
||||
if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
|
||||
return state.cards.filter(c => c.bucket_id == bv);
|
||||
if (!bv) return cards;
|
||||
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
||||
return cards.filter(c => c.bucket_id == bv);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
@@ -80,12 +126,12 @@ function updatePreview() {
|
||||
|
||||
function renderHome() {
|
||||
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>` +
|
||||
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
||||
hits = recent; active = recent.length ? 0 : -1;
|
||||
footer.classList.remove('hidden');
|
||||
bindHits(); updateActive(); updatePreview();
|
||||
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||
}
|
||||
|
||||
function search(query) {
|
||||
@@ -110,10 +156,10 @@ function search(query) {
|
||||
// Tag-only search (no words)
|
||||
if (!words.length) {
|
||||
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>` +
|
||||
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 => {
|
||||
@@ -141,10 +187,10 @@ function search(query) {
|
||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||
|
||||
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>` +
|
||||
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) {
|
||||
@@ -246,3 +292,44 @@ input.addEventListener('keydown', e => {
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
||||
$('search-btn')?.addEventListener('click', openSearch);
|
||||
$('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: [],
|
||||
buckets: [],
|
||||
|
||||
// Filters & view
|
||||
activeBucket: 'all',
|
||||
// Filters & view — activeBucket is persisted across reloads via
|
||||
// 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: [],
|
||||
activeGroup: null,
|
||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||
@@ -23,6 +27,21 @@ export const state = {
|
||||
|
||||
// Multi-select
|
||||
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
|
||||
editingId: null,
|
||||
@@ -47,6 +66,28 @@ export const state = {
|
||||
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' };
|
||||
|
||||
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 = [
|
||||
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
||||
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
||||
|
||||
+337
-4
@@ -10,7 +10,7 @@ const themeList = $('settings-theme-list');
|
||||
// Render theme list in settings
|
||||
function renderThemes() {
|
||||
if (!themeList) return;
|
||||
const current = localStorage.getItem('hyprcrd-theme') || 'slate';
|
||||
const current = (designPending?.theme) || localStorage.getItem('hyprcrd-theme') || 'slate';
|
||||
themeList.innerHTML = '';
|
||||
for (const [key, t] of Object.entries(window.themes)) {
|
||||
const btn = document.createElement('button');
|
||||
@@ -19,7 +19,16 @@ function renderThemes() {
|
||||
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
|
||||
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
|
||||
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
|
||||
btn.onclick = () => { window.applyTheme(key); renderThemes(); window._renderBoard?.(); };
|
||||
btn.onclick = () => {
|
||||
window.applyTheme(key);
|
||||
if (designPending) {
|
||||
designPending.theme = key;
|
||||
window.saveDesign(designPending);
|
||||
flashSaved();
|
||||
}
|
||||
renderThemes();
|
||||
window._renderBoard?.();
|
||||
};
|
||||
themeList.appendChild(btn);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +45,328 @@ overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
||||
if (panel === 'notifications') renderNotifSettings();
|
||||
if (panel === 'behavior') initBehaviorSettings();
|
||||
if (panel === 'theme') initDesignSettings();
|
||||
if (panel === 'plugins') window._openPluginSettings?.();
|
||||
if (panel !== 'theme') setPeekUI(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Design (fonts, geometry, borders, spacing, effects, presets) ──
|
||||
let designPending = null;
|
||||
|
||||
const SLIDER_FIELDS = [
|
||||
['setting-radius-card', 'radiusCard', 'val-radius-card'],
|
||||
['setting-radius-button', 'radiusButton', 'val-radius-button'],
|
||||
['setting-radius-input', 'radiusInput', 'val-radius-input'],
|
||||
['setting-radius-modal', 'radiusModal', 'val-radius-modal'],
|
||||
['setting-radius-tag', 'radiusTag', 'val-radius-tag'],
|
||||
['setting-radius-image', 'radiusImage', 'val-radius-image'],
|
||||
['setting-border-card', 'borderCard', 'val-border-card'],
|
||||
['setting-bar-height', 'barHeight', 'val-bar-height'],
|
||||
['setting-pad-card', 'padCard', 'val-pad-card'],
|
||||
['setting-gap-board', 'gapBoard', 'val-gap-board'],
|
||||
['setting-lift-card', 'liftCard', 'val-lift-card'],
|
||||
['setting-blur-overlay', 'blurOverlay', 'val-blur-overlay'],
|
||||
];
|
||||
const SELECT_FIELDS = [
|
||||
['setting-font-headline', 'fontHeadline'],
|
||||
['setting-font-content', 'fontContent'],
|
||||
['setting-font-ui', 'fontUi'],
|
||||
['setting-border-style', 'borderStyle'],
|
||||
['setting-shadow-card', 'shadowCard'],
|
||||
['setting-shadow-modal', 'shadowModal'],
|
||||
['setting-shadow-ui', 'shadowUi'],
|
||||
['setting-anim-speed', 'animSpeed'],
|
||||
];
|
||||
|
||||
function updatePreview() {
|
||||
const preview = $('theme-preview');
|
||||
if (!preview || !designPending) return;
|
||||
const d = designPending, fonts = window.themeFonts, shadows = window.designShadows;
|
||||
const set = (k, v) => preview.style.setProperty(k, v);
|
||||
set('--tp-font-headline', fonts[d.fontHeadline] || fonts.pixel);
|
||||
set('--tp-font-content', fonts[d.fontContent] || fonts.mono);
|
||||
set('--tp-font-ui', fonts[d.fontUi] || fonts.pixel);
|
||||
set('--tp-radius-card', d.radiusCard + 'px');
|
||||
set('--tp-radius-button', d.radiusButton + 'px');
|
||||
set('--tp-radius-input', d.radiusInput + 'px');
|
||||
set('--tp-radius-tag', d.radiusTag + 'px');
|
||||
set('--tp-border-card', d.borderCard + 'px');
|
||||
set('--tp-border-style', d.borderStyle);
|
||||
set('--tp-bar-height', d.barHeight + 'px');
|
||||
set('--tp-pad-card', d.padCard + 'px');
|
||||
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
||||
const scan = $('theme-preview-scan');
|
||||
if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : 'none';
|
||||
}
|
||||
|
||||
function fillFontSelect(id, value) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
for (const [key, label] of Object.entries(window.themeFontLabels)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key; opt.textContent = label;
|
||||
if (key === value) opt.selected = true;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function syncControlsFromPending() {
|
||||
if (!designPending) return;
|
||||
fillFontSelect('setting-font-headline', designPending.fontHeadline);
|
||||
fillFontSelect('setting-font-content', designPending.fontContent);
|
||||
fillFontSelect('setting-font-ui', designPending.fontUi);
|
||||
for (const [id, key] of SELECT_FIELDS) {
|
||||
if (id.startsWith('setting-font-')) continue;
|
||||
const el = $(id);
|
||||
if (el) el.value = String(designPending[key] ?? '');
|
||||
}
|
||||
for (const [id, key, valId] of SLIDER_FIELDS) {
|
||||
const v = String(designPending[key] ?? 0);
|
||||
const el = $(id);
|
||||
if (el) el.value = v;
|
||||
const valEl = $(valId);
|
||||
if (valEl) valEl.textContent = v;
|
||||
}
|
||||
const scanEl = $('setting-scanlines');
|
||||
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
|
||||
}
|
||||
|
||||
function commitDesign() {
|
||||
updatePreview();
|
||||
window.applyDesign(designPending);
|
||||
window.saveDesign(designPending);
|
||||
flashSaved();
|
||||
}
|
||||
|
||||
let saveFlashTimer = null;
|
||||
function flashSaved() {
|
||||
const status = $('design-status');
|
||||
if (!status) return;
|
||||
status.textContent = 'saved';
|
||||
status.style.color = 'var(--t-accent)';
|
||||
clearTimeout(saveFlashTimer);
|
||||
saveFlashTimer = setTimeout(() => {
|
||||
status.textContent = 'auto-saving';
|
||||
status.style.color = 'var(--t-dim)';
|
||||
}, 700);
|
||||
}
|
||||
|
||||
let designWired = false;
|
||||
|
||||
function initDesignSettings() {
|
||||
designPending = window.loadDesign();
|
||||
syncControlsFromPending();
|
||||
updatePreview();
|
||||
refreshPresetSelect();
|
||||
|
||||
const status = $('design-status');
|
||||
if (status) { status.textContent = 'auto-saving'; status.style.color = 'var(--t-dim)'; }
|
||||
|
||||
if (designWired) return;
|
||||
designWired = true;
|
||||
|
||||
// Scanlines toggle
|
||||
const scanEl = $('setting-scanlines');
|
||||
const scanRow = scanEl?.closest('.toggle-row');
|
||||
if (scanRow) scanRow.onclick = () => {
|
||||
const nowOn = !scanEl.classList.contains('on');
|
||||
scanEl.classList.toggle('on', nowOn);
|
||||
designPending.scanlines = nowOn ? '1' : '0';
|
||||
commitDesign();
|
||||
};
|
||||
|
||||
// Selects
|
||||
for (const [id, key] of SELECT_FIELDS) {
|
||||
const el = $(id);
|
||||
if (!el) continue;
|
||||
el.addEventListener('change', () => {
|
||||
designPending[key] = el.value;
|
||||
commitDesign();
|
||||
});
|
||||
}
|
||||
|
||||
// Sliders
|
||||
for (const [id, key, valId] of SLIDER_FIELDS) {
|
||||
const el = $(id);
|
||||
if (!el) continue;
|
||||
el.addEventListener('input', () => {
|
||||
const num = Number(el.value);
|
||||
designPending[key] = num;
|
||||
const v = $(valId);
|
||||
if (v) v.textContent = num;
|
||||
commitDesign();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('design-reset')?.addEventListener('click', () => {
|
||||
designPending = { ...window.designDefaults };
|
||||
syncControlsFromPending();
|
||||
commitDesign();
|
||||
renderThemes();
|
||||
});
|
||||
|
||||
// ── Peek toggle (hide background dim so the real board is visible behind the popup) ──
|
||||
function setPeekUI(peeking) {
|
||||
document.body.classList.toggle('settings-peeking', peeking);
|
||||
const sw = $('design-peek-toggle');
|
||||
if (sw) sw.classList.toggle('on', peeking);
|
||||
}
|
||||
const peekRow = $('design-peek-row');
|
||||
if (peekRow) peekRow.onclick = () => {
|
||||
setPeekUI(!document.body.classList.contains('settings-peeking'));
|
||||
};
|
||||
|
||||
// ── Presets ──
|
||||
|
||||
function loadUserPresets() {
|
||||
try { return JSON.parse(localStorage.getItem('hyprcrd-design-presets') || '{}'); }
|
||||
catch { return {}; }
|
||||
}
|
||||
function saveUserPresets(p) {
|
||||
localStorage.setItem('hyprcrd-design-presets', JSON.stringify(p));
|
||||
}
|
||||
|
||||
function refreshPresetSelect() {
|
||||
const sel = $('design-preset-select');
|
||||
if (!sel) return;
|
||||
// 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 = '';
|
||||
const builtinGroup = document.createElement('optgroup');
|
||||
builtinGroup.label = 'built-in';
|
||||
for (const name of Object.keys(window.builtinPresets || {})) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'builtin:' + name; opt.textContent = name;
|
||||
builtinGroup.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(builtinGroup);
|
||||
const user = loadUserPresets();
|
||||
const names = Object.keys(user);
|
||||
if (names.length) {
|
||||
const userGroup = document.createElement('optgroup');
|
||||
userGroup.label = 'custom';
|
||||
for (const name of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'user:' + name; opt.textContent = name;
|
||||
userGroup.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(userGroup);
|
||||
}
|
||||
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() {
|
||||
const sel = $('design-preset-select');
|
||||
if (!sel?.value) return null;
|
||||
const [kind, ...rest] = sel.value.split(':');
|
||||
const name = rest.join(':');
|
||||
if (kind === 'builtin') return window.builtinPresets?.[name] ? { ...window.builtinPresets[name] } : null;
|
||||
if (kind === 'user') return loadUserPresets()[name] ? { ...loadUserPresets()[name] } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadSelectedPreset() {
|
||||
const cfg = getSelectedPreset();
|
||||
if (!cfg) return;
|
||||
designPending = { ...window.designDefaults, ...cfg };
|
||||
syncControlsFromPending();
|
||||
commitDesign();
|
||||
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-save')?.addEventListener('click', () => {
|
||||
const nameEl = $('design-preset-name');
|
||||
const name = (nameEl?.value || '').trim();
|
||||
if (!name) { nameEl?.focus(); return; }
|
||||
const presets = loadUserPresets();
|
||||
presets[name] = { ...designPending };
|
||||
saveUserPresets(presets);
|
||||
if (nameEl) nameEl.value = '';
|
||||
refreshPresetSelect();
|
||||
const sel = $('design-preset-select');
|
||||
if (sel) sel.value = 'user:' + name;
|
||||
localStorage.setItem('hyprcrd-design-active-preset', 'user:' + name);
|
||||
const status = $('design-status');
|
||||
if (status) { status.textContent = `preset "${name}" saved`; status.style.color = 'var(--t-accent)'; }
|
||||
});
|
||||
|
||||
$('design-preset-delete')?.addEventListener('click', () => {
|
||||
const sel = $('design-preset-select');
|
||||
if (!sel?.value?.startsWith('user:')) return;
|
||||
const name = sel.value.slice('user:'.length);
|
||||
const presets = loadUserPresets();
|
||||
delete presets[name];
|
||||
saveUserPresets(presets);
|
||||
if (localStorage.getItem('hyprcrd-design-active-preset') === 'user:' + name) {
|
||||
localStorage.removeItem('hyprcrd-design-active-preset');
|
||||
}
|
||||
refreshPresetSelect();
|
||||
});
|
||||
|
||||
$('design-preset-export')?.addEventListener('click', async () => {
|
||||
const cfg = designPending || window.loadDesign();
|
||||
const json = JSON.stringify(cfg, null, 2);
|
||||
// Best-effort clipboard (silently fails on non-secure contexts)
|
||||
let copied = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(json);
|
||||
copied = true;
|
||||
}
|
||||
} catch {}
|
||||
// Always also trigger a file download — works regardless of clipboard support
|
||||
try {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'hyprcrd-design.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
console.error('design export failed', e);
|
||||
}
|
||||
const status = $('design-status');
|
||||
if (status) {
|
||||
status.textContent = copied ? 'downloaded + copied to clipboard' : 'downloaded hyprcrd-design.json';
|
||||
status.style.color = 'var(--t-accent)';
|
||||
}
|
||||
});
|
||||
|
||||
$('design-preset-import')?.addEventListener('click', async () => {
|
||||
const { showDialog } = await import('./dialog.js');
|
||||
const json = await showDialog({ title: 'Import design JSON', input: true, placeholder: '{"radiusCard":8,...}', okLabel: 'Import' });
|
||||
if (!json) return;
|
||||
try {
|
||||
const cfg = JSON.parse(json);
|
||||
designPending = { ...window.designDefaults, ...cfg };
|
||||
syncControlsFromPending();
|
||||
commitDesign();
|
||||
renderThemes();
|
||||
} catch {
|
||||
const status = $('design-status');
|
||||
if (status) { status.textContent = 'invalid JSON'; status.style.color = 'var(--t-danger)'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Open/Close
|
||||
function updateStats() {
|
||||
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
|
||||
@@ -77,12 +404,17 @@ async function loadVaultStatus() {
|
||||
|
||||
$('settings-btn')?.addEventListener('click', () => {
|
||||
renderThemes();
|
||||
initDesignSettings();
|
||||
updateStats();
|
||||
loadVaultStatus();
|
||||
overlay?.classList.add('open');
|
||||
});
|
||||
$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
|
||||
overlay?.addEventListener('click', e => { if (e.target === overlay) overlay?.classList.remove('open'); });
|
||||
function closeSettings() {
|
||||
overlay?.classList.remove('open');
|
||||
setPeekUI(false);
|
||||
}
|
||||
$('settings-close')?.addEventListener('click', closeSettings);
|
||||
overlay?.addEventListener('click', e => { if (e.target === overlay) closeSettings(); });
|
||||
|
||||
// Password change
|
||||
$('settings-pw-save')?.addEventListener('click', async () => {
|
||||
@@ -123,6 +455,7 @@ function initBehaviorSettings() {
|
||||
'setting-card-width': { key: 'hyprcrd-card-width', def: '280' },
|
||||
'setting-body-lines': { key: 'hyprcrd-body-lines', def: '5' },
|
||||
'setting-click-action': { key: 'hyprcrd-click-action', def: 'preview' },
|
||||
'setting-esc-action': { key: 'hyprcrd-esc-action', def: 'close' },
|
||||
};
|
||||
const toggles = {
|
||||
'setting-current-include-synced': { key: 'hyprcrd-current-include-synced', def: '0' },
|
||||
|
||||
@@ -66,21 +66,29 @@ export function pushBatch(label, patches) {
|
||||
}
|
||||
|
||||
// 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;
|
||||
function showHint(text) {
|
||||
let el = document.getElementById('undo-hint');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
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);
|
||||
}
|
||||
el.textContent = text + ' (ctrl+z undo)';
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateX(-50%) translateY(0)';
|
||||
el.style.translate = '-50% 0';
|
||||
clearTimeout(hintTimer);
|
||||
hintTimer = setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
el.style.translate = '-50% 8px';
|
||||
}, 2500);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
# hyprcrd plugin system — mindmap
|
||||
|
||||
A complete architectural reference. Don't lose state on this — every detail
|
||||
here was learned the hard way during the canvas/tests/email plugin builds.
|
||||
|
||||
```
|
||||
hyprcrd plugin system
|
||||
│
|
||||
├── Discovery (PHP)
|
||||
│ ├── src/PluginManager.php (singleton)
|
||||
│ │ ├── scan(dir) → reads each subdir's plugin.json
|
||||
│ │ ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]}
|
||||
│ │ ├── loadBackends() → require_once enabled backend.php files
|
||||
│ │ └── isEnabled(name) → checked by cssLinks/htmlFragments/scriptTags
|
||||
│ └── src/bootstrap.php (call order matters!)
|
||||
│ 1. config.php
|
||||
│ 2. Database.php
|
||||
│ 3. PluginManager.php
|
||||
│ 4. PluginManager::scan(public/plugins)
|
||||
│ 5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json')
|
||||
│ 6. PluginManager::loadBackends() ← AFTER loadDisabled, so disabled plugins skip backend.php too
|
||||
│
|
||||
├── plugin.json manifest format
|
||||
│ ├── name (string, unique)
|
||||
│ ├── version (string)
|
||||
│ ├── description (string)
|
||||
│ ├── frontend
|
||||
│ │ ├── js: [...] (loaded as <script type="module">)
|
||||
│ │ ├── css: [...] (loaded as <link>)
|
||||
│ │ └── html: [...] (file contents inlined into body via htmlFragments())
|
||||
│ └── backend (string, optional path to backend.php)
|
||||
│
|
||||
├── Core injection points (PHP, in index.php)
|
||||
│ ├── In <head> after style.css: <?= PluginManager::instance()->cssLinks() ?>
|
||||
│ ├── In #md-toolbar after extract: <?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||
│ ├── Before </body>: <?= PluginManager::instance()->htmlFragments() ?>
|
||||
│ ├── Then synchronously: <script src="js/hyprcrd.js"></script>
|
||||
│ ├── Then plugin entries: <?= PluginManager::instance()->scriptTags() ?>
|
||||
│ ├── Then core ES module: <script type="module" src="js/app.js">
|
||||
│ └── Inside auth-protected API block, BEFORE first /api/cards check:
|
||||
│ PluginManager::instance()->dispatchRoute($route, $method);
|
||||
│
|
||||
├── Frontend API: window.hyprcrd (defined in public/js/hyprcrd.js, sync-loaded)
|
||||
│ │
|
||||
│ ├── hooks (generic event bus)
|
||||
│ │ ├── on(name, fn) register listener
|
||||
│ │ ├── off(name, fn) remove
|
||||
│ │ └── emit(name, data) → data listeners can mutate via return value
|
||||
│ │
|
||||
│ ├── editor (toolbar command registry)
|
||||
│ │ ├── registerCommand(name, fn) buttons with data-md="<name>" dispatch here
|
||||
│ │ ├── unregisterCommand(name)
|
||||
│ │ ├── getCommand(name)
|
||||
│ │ └── api ← set by editor.js AFTER its init
|
||||
│ │ ├── body the textarea element
|
||||
│ │ ├── insertMd(b, a, ph) inserts markdown around selection
|
||||
│ │ └── updatePreview() re-renders the preview pane
|
||||
│ │
|
||||
│ ├── markdown (registry, applied in order)
|
||||
│ │ ├── registerFencedBlock(lang, fn) ```<lang> blocks → handler returns HTML
|
||||
│ │ ├── registerPreProcessor(fn) runs on source text before marked.parse()
|
||||
│ │ ├── registerPostProcessor(fn) runs on HTML after marked.parse(), before DOMPurify
|
||||
│ │ ├── getFencedBlocks()
|
||||
│ │ ├── getPreProcessors()
|
||||
│ │ └── getPostProcessors()
|
||||
│ │ └── handler signature: fn(content, { esc, normalizeListBlock }) → htmlString
|
||||
│ │
|
||||
│ ├── settings (settings overlay tab registry)
|
||||
│ │ ├── registerTab({id, label, icon, onShow})
|
||||
│ │ │ ├── injects button into #settings-nav
|
||||
│ │ │ ├── creates empty <div id="settings-panel-{id}"> in panels container
|
||||
│ │ │ ├── attaches click handler (active state + emit hook + onShow)
|
||||
│ │ │ └── icon = phosphor class like 'ph-envelope', defaults to ph-puzzle-piece
|
||||
│ │ ├── open() clicks #settings-btn
|
||||
│ │ └── openTab(id) opens settings + switches to that tab
|
||||
│ │
|
||||
│ ├── util
|
||||
│ │ ├── $(idOrSel) querySelector or getElementById
|
||||
│ │ ├── $$(sel) querySelectorAll → Array
|
||||
│ │ ├── esc(s) HTML escape
|
||||
│ │ ├── setCsrf(token) called by app.js after auth
|
||||
│ │ └── csrf() read by plugins for API calls
|
||||
│ │
|
||||
│ ├── api(path, opts) fetch wrapper, auto X-CSRF-Token header
|
||||
│ │
|
||||
│ ├── dialog (themed confirm/prompt — NEVER use native confirm())
|
||||
│ │ ├── show(opts) → core dialog.js fills this in after load
|
||||
│ │ │ opts: {title, message, input, danger, okLabel, cancelLabel}
|
||||
│ │ │ returns Promise<value|null> (null = cancelled)
|
||||
│ │ └── confirm(msg, opts) convenience: returns Promise<true|null>
|
||||
│ │ opts: {title, okLabel, cancelLabel, danger}
|
||||
│ │
|
||||
│ └── registerPlugin({name, version, description})
|
||||
│ └── pushes to hyprcrd.plugins[] for the settings → plugins UI
|
||||
│
|
||||
├── Backend API extension (PHP, from plugin's backend.php)
|
||||
│ └── PluginManager::instance()->onApiRoute(function($route, $method) {
|
||||
│ if ($route === '/api/foo' && $method === 'GET') {
|
||||
│ // ... must call exit() on match
|
||||
│ echo json_encode([...]);
|
||||
│ exit;
|
||||
│ }
|
||||
│ });
|
||||
│ │
|
||||
│ ├── handlers run in registration order, before core route handling
|
||||
│ ├── pass-through if none exit → core routes get a chance
|
||||
│ ├── CSRF middleware in core runs BEFORE dispatchRoute, so plugin
|
||||
│ │ routes are automatically CSRF-protected for mutations
|
||||
│ └── auth check also runs BEFORE → all plugin routes require login
|
||||
│
|
||||
├── Lifecycle / load order (CRITICAL for timing bugs)
|
||||
│ 1. Browser parses HTML
|
||||
│ 2. <link rel="stylesheet"> tags loaded (core + plugin CSS)
|
||||
│ 3. <?= htmlFragments() ?> → plugin overlay HTML now in DOM
|
||||
│ 4. <script src="js/hyprcrd.js"> → SYNCHRONOUS, window.hyprcrd defined
|
||||
│ 5. <script type="module" src="plugins/.../*.js"> → DEFERRED, run after parse
|
||||
│ ├── Plugin modules execute in document order
|
||||
│ ├── At this point: DOM ready, window.hyprcrd ready, core editor.js NOT loaded yet
|
||||
│ ├── So hyprcrd.editor.api is null until app.js runs
|
||||
│ └── Plugin commands that USE editor.api should defer (only fire on user click)
|
||||
│ 6. <script type="module" src="js/app.js"> → orchestrator
|
||||
│ ├── imports core modules (state, theme, editor, markdown, ...)
|
||||
│ ├── markdown.js top-level: registers gallery/tree/bullets via the SAME registry
|
||||
│ ├── editor.js top-level: registers heading/bold/italic/etc + sets editor.api
|
||||
│ └── checkAuth() → setCsrf() → window.hyprcrd.util.setCsrf() (mirrored)
|
||||
│
|
||||
├── Settings tab race (avoided by registerTab design)
|
||||
│ ├── theme.js attaches click handlers to all .settings-tab at module load
|
||||
│ ├── If plugin's tab is added BEFORE theme.js runs (default), theme.js handler ALSO attaches
|
||||
│ ├── Result: both core handler AND plugin click handler fire on click
|
||||
│ ├── Both manage active states identically — harmless duplicate work
|
||||
│ └── Plugin's onShow is called by the plugin handler, NOT by core
|
||||
│
|
||||
├── Disable mechanism
|
||||
│ ├── State file: data/plugins.json → {"disabled": ["canvas", ...]}
|
||||
│ ├── Settings UI: settings → plugins → toggle switches
|
||||
│ ├── API: GET /api/plugins, POST /api/plugins/toggle {name, enabled}
|
||||
│ ├── Disabled plugins are skipped in cssLinks, htmlFragments, scriptTags, AND loadBackends
|
||||
│ ├── Toggling requires page reload (already-loaded code can't unload)
|
||||
│ └── Reload notice in plugins panel; dirtyAfterToggle flag persists until reload
|
||||
│
|
||||
├── Conventions
|
||||
│ ├── Plugins NEVER import from core modules. Use window.hyprcrd globals only.
|
||||
│ ├── Plugin JS is ES module (loaded as type="module") so import/export works WITHIN the plugin
|
||||
│ ├── Plugin self-registration happens at module top level (no init function needed)
|
||||
│ ├── Plugin DOM injection should idempotently check if element already exists
|
||||
│ │ (canvas: btn.querySelector('[data-md="asciiart"]') guard)
|
||||
│ ├── If a plugin needs DOM that may not exist yet (e.g. injectToolbarButton),
|
||||
│ │ wrap in DOMContentLoaded check OR run immediately if document.readyState !== 'loading'
|
||||
│ ├── Plugin schema migrations live in backend.php as IIFE that runs once per request
|
||||
│ │ (cheap CREATE TABLE IF NOT EXISTS)
|
||||
│ └── Plugin credentials → reuse vault key encryption (sha256(vault_key) substr 0..32 + AES-256-CBC)
|
||||
│
|
||||
├── Existing plugins
|
||||
│ ├── canvas/ (~500 lines)
|
||||
│ │ ├── No backend
|
||||
│ │ ├── Registers ascii fenced block + asciiart editor command + toolbar btn (JS injected)
|
||||
│ │ ├── Has its own overlay (canvas.html), tools, palette
|
||||
│ │ └── Edit-mode: detects cursor inside ```ascii block, loads existing content
|
||||
│ ├── tests/ (~40 test files + runner.js + loader.js)
|
||||
│ │ ├── No backend
|
||||
│ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
|
||||
│ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
|
||||
│ │ └── Both static AND dynamic imports use that path — sed both when moving files
|
||||
│ ├── email/ (~1300 lines, biggest)
|
||||
│ │ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
|
||||
│ │ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
|
||||
│ │ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
|
||||
│ │ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
|
||||
│ │ │ └── onShow rebuilds form on first show, refreshes accounts list every show
|
||||
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||
│ ├── logseq-import/ (~200 lines)
|
||||
│ │ ├── Backend: reads ZIP, copies assets → uploads/, imports journals + pages
|
||||
│ │ ├── Route: POST /api/import-logseq (multipart form, file field)
|
||||
│ │ ├── Creates "import" bucket if missing; journals → blue, pages → violet
|
||||
│ │ ├── Strips Logseq bullet prefixes and `key:: value` property lines
|
||||
│ │ ├── Rewrites asset refs via filename → random-hex map
|
||||
│ │ └── Frontend injects button + file input into footer before #settings-btn,
|
||||
│ │ posts to /api/import-logseq, calls window._reloadAll() on success
|
||||
│ └── slideshow/ (~500 lines)
|
||||
│ ├── No backend — data stored as ```slide-image fenced block in card body
|
||||
│ ├── Slideshow = parent card (start slide) + children (in position order)
|
||||
│ ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render
|
||||
│ ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %)
|
||||
│ │ └── Targets: card:<id>, slide:next, slide:prev, slide:first, slide:<index>
|
||||
│ ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter
|
||||
│ │ ├── Image slides use object-fit:contain; hotspots positioned relative to img box
|
||||
│ │ ├── Arrow keys always work as fallback, even when hotspots are present
|
||||
│ │ └── 'h' key toggles debug-hotspots class to visualize rects during presentation
|
||||
│ ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick
|
||||
│ ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id]
|
||||
│ │ └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch
|
||||
│ └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor
|
||||
│
|
||||
├── Gotchas / lessons learned
|
||||
│ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
|
||||
│ ├── inline-block vs block — pre with display:block stretches to container width,
|
||||
│ │ making mouse-cell math wrong. Use width:max-content.
|
||||
│ ├── auto-fit canvas — measure ONE char (set textContent='M'), then floor(container/charW)
|
||||
│ ├── DOMPurify strips HTML comments — list:tree/bullets uses comments as markers, then
|
||||
│ │ converts to classes BEFORE sanitize
|
||||
│ ├── ALLOWED_TAGS in markdown.js — must include any tag plugins emit (pre, span, div ok)
|
||||
│ ├── ALLOWED_ATTR — class, style, data-* are allowed; check before adding novel attrs
|
||||
│ ├── theme.js handles tab switching for ALL .settings-tab, including dynamically added ones,
|
||||
│ │ because forEach runs at module load AFTER plugins have injected their tabs
|
||||
│ ├── CSRF token sync — app.js stores it via setCsrf() in util.js (module-private),
|
||||
│ │ also calls window.hyprcrd.util.setCsrf() so plugins can read it
|
||||
│ ├── Plugin tests can break core tab counts — don't hardcode toBe(N), use toContain checks
|
||||
│ └── Header button injection — querySelector('#main-header .flex.items-center.gap-3:last-of-type')
|
||||
│ to grab the right-side button group (calendar, gallery, etc.)
|
||||
│
|
||||
└── Adding a new plugin (checklist)
|
||||
1. mkdir public/plugins/<name>
|
||||
2. Write plugin.json (name, version, description, frontend.{js,css,html}, backend?)
|
||||
3. If JS: import nothing from core. Use window.hyprcrd.* exclusively.
|
||||
4. If HTML: provide overlay/modal markup. Use IDs prefixed with plugin name to avoid clashes.
|
||||
5. If CSS: scope selectors to plugin-specific classes/IDs.
|
||||
6. If backend: require_once your classes, define helpers in named functions (no closures
|
||||
at top level — they pollute the global scope), register routes via onApiRoute().
|
||||
7. If schema: CREATE TABLE IF NOT EXISTS in an IIFE at top of backend.php.
|
||||
8. If credentials: reuse the vault key encryption pattern (see email/backend.php).
|
||||
9. If settings: hyprcrd.settings.registerTab({...}) at the end of init().
|
||||
10. Test by reloading; check window.hyprcrd.plugins includes your meta entry.
|
||||
11. Verify it can be disabled via settings → plugins (assets must vanish from page source).
|
||||
12. If adding a new core hook is unavoidable, document it HERE before merging.
|
||||
```
|
||||
|
||||
## API quick reference (copy-paste safe)
|
||||
|
||||
### Register a markdown fenced block
|
||||
|
||||
```js
|
||||
window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
|
||||
return `<div class="my-block">${esc(content)}</div>`;
|
||||
});
|
||||
```
|
||||
|
||||
### Register an editor toolbar command
|
||||
|
||||
```js
|
||||
window.hyprcrd.editor.registerCommand('mycmd', () => {
|
||||
const ed = window.hyprcrd.editor.api;
|
||||
if (!ed) return; // editor not initialized yet (shouldn't happen on user click)
|
||||
ed.insertMd('<<', '>>', 'placeholder');
|
||||
});
|
||||
|
||||
// Add a button with data-md="mycmd" to #md-toolbar via JS:
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.md = 'mycmd';
|
||||
btn.className = 'toolbar-btn';
|
||||
btn.innerHTML = '<i class="ph ph-star text-sm"></i>';
|
||||
document.getElementById('md-toolbar').appendChild(btn);
|
||||
```
|
||||
|
||||
### Register a settings tab
|
||||
|
||||
```js
|
||||
window.hyprcrd.settings.registerTab({
|
||||
id: 'mything',
|
||||
label: 'my thing',
|
||||
icon: 'ph-star',
|
||||
onShow: (panelEl) => {
|
||||
panelEl.innerHTML = `<p>Settings UI here</p>`;
|
||||
},
|
||||
});
|
||||
|
||||
// Open it programmatically:
|
||||
window.hyprcrd.settings.openTab('mything');
|
||||
```
|
||||
|
||||
### Register a backend API route (in backend.php)
|
||||
|
||||
```php
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($route === '/api/myplugin/things' && $method === 'GET') {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Make a CSRF-protected API call (frontend)
|
||||
|
||||
```js
|
||||
const result = await window.hyprcrd.api('api/myplugin/things', {
|
||||
method: 'POST',
|
||||
body: { name: 'foo' },
|
||||
});
|
||||
```
|
||||
|
||||
### Encrypt credentials (in backend.php, reusing the vault key)
|
||||
|
||||
```php
|
||||
function myEncKey(): string {
|
||||
$db = Database::connect();
|
||||
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
function myEncrypt(string $plain): string {
|
||||
$key = myEncKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
function myDecrypt(string $encoded): string {
|
||||
$key = myEncKey();
|
||||
$raw = base64_decode($encoded);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Chat Plugin: backend.
|
||||
*
|
||||
* Local message cache + thin proxy over the relays /api/chat routes.
|
||||
* The plugin keeps a local SQLite table so the UI can render conversation
|
||||
* history without re-polling on every open. Reuses the existing relay_url /
|
||||
* relay_username / relay_key settings (no new credentials).
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/chat/conversations → peer list with last msg + unread
|
||||
* GET /api/chat/messages?peer=X → all messages with one peer (asc)
|
||||
* POST /api/chat/send {peer,body} → encrypts + pushes via relay, stores local
|
||||
* POST /api/chat/poll → pulls new from relay, returns count
|
||||
* POST /api/chat/read {peer} → mark all incoming from peer as read
|
||||
* GET /api/chat/recipients → proxy of relays /api/tenants
|
||||
*/
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
body TEXT NOT NULL,
|
||||
relay_id INTEGER,
|
||||
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_read INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
");
|
||||
$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");
|
||||
// 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)
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
");
|
||||
})();
|
||||
|
||||
function chatJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function chatRelaySettings(): 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() ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
|
||||
function chatEncrypt(string $plain, string $relayKey): string {
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
|
||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||
$raw = base64_decode($b64);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return $result !== false ? $result : '';
|
||||
}
|
||||
|
||||
function chatGetState(string $key, $default = null) {
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT value FROM chat_state WHERE key = :k');
|
||||
$stmt->execute(['k' => $key]);
|
||||
$v = $stmt->fetchColumn();
|
||||
return $v === false ? $default : $v;
|
||||
}
|
||||
|
||||
function chatSetState(string $key, $value): void {
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT OR REPLACE INTO chat_state (key, value) VALUES (:k, :v)')
|
||||
->execute(['k' => $key, 'v' => (string)$value]);
|
||||
}
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
if ($route === '/api/chat/conversations' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
// Latest message per peer + unread count, single query
|
||||
$rows = $db->query("
|
||||
SELECT
|
||||
peer,
|
||||
MAX(sent_at) AS last_at,
|
||||
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_direction,
|
||||
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
|
||||
FROM chat_messages c1
|
||||
GROUP BY peer
|
||||
ORDER BY last_at DESC
|
||||
")->fetchAll();
|
||||
$totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows));
|
||||
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/messages' && $method === 'GET') {
|
||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$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]);
|
||||
chatJson(['messages' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/send' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
$body = (string)($input['body'] ?? '');
|
||||
if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400);
|
||||
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
|
||||
}
|
||||
|
||||
$envelope = chatEncrypt($body, $r['key']);
|
||||
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
|
||||
$ch = curl_init($relayChatUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200) {
|
||||
$relayErr = '';
|
||||
$decoded = json_decode($response, true);
|
||||
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||
chatJson([
|
||||
'ok' => false,
|
||||
'error' => "relay returned HTTP $status$relayErr",
|
||||
'url' => $relayChatUrl,
|
||||
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$resData = json_decode($response, true);
|
||||
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||
|
||||
// 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->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, 'rid' => $relayId]);
|
||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/poll' && $method === 'POST') {
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['ok' => true, 'new' => 0, 'note' => 'relay not configured']);
|
||||
}
|
||||
$since = (int)chatGetState('last_relay_id', 0);
|
||||
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat?since=' . $since);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200) {
|
||||
$relayErr = '';
|
||||
$decoded = json_decode($response, true);
|
||||
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||
chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]);
|
||||
}
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
$messages = $data['messages'] ?? [];
|
||||
$maxId = (int)($data['max_id'] ?? $since);
|
||||
|
||||
$db = Database::connect();
|
||||
$newCount = 0;
|
||||
$insert = $db->prepare('INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "in", :b, :rid, :ts, 0)');
|
||||
foreach ($messages as $m) {
|
||||
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||
if ($plain === '') continue;
|
||||
$insert->execute([
|
||||
'p' => $m['sender_username'] ?? '?',
|
||||
'b' => $plain,
|
||||
'rid' => (int)$m['id'],
|
||||
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
]);
|
||||
if ($insert->rowCount() > 0) $newCount++;
|
||||
}
|
||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||
|
||||
// ── 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') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
||||
->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]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/recipients' && $method === 'GET') {
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['ok' => false, 'error' => 'relay not configured']);
|
||||
}
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/tenants');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($status !== 200) chatJson(['ok' => false, 'error' => "relay returned HTTP $status"]);
|
||||
$data = json_decode($response, true) ?: [];
|
||||
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/* ── Chat plugin styles ── */
|
||||
|
||||
#chat-flyout {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
right: 16px;
|
||||
width: 340px;
|
||||
height: 460px;
|
||||
max-height: calc(100vh - 100px);
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-modal);
|
||||
box-shadow: var(--ui-shadow-modal);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#chat-flyout.hidden { display: none; }
|
||||
|
||||
#chat-flyout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-view.hidden { display: none; }
|
||||
|
||||
/* ── Conversation list ── */
|
||||
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* When empty, collapse so the `.chat-empty` placeholder can fill and center
|
||||
properly instead of being pushed into the lower half of the view. */
|
||||
.chat-list:empty {
|
||||
flex: 0 0 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-list-row:hover {
|
||||
background: var(--t-raised);
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-raised);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--t-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-peer {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
color: var(--t-bright);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-time {
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-preview {
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-list-row .chat-list-preview.outgoing::before {
|
||||
content: "› ";
|
||||
color: var(--t-dim);
|
||||
}
|
||||
|
||||
.chat-list-row .chat-unread-badge {
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.chat-empty > p {
|
||||
margin: 0;
|
||||
}
|
||||
#chat-empty-start {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ── Thread view ── */
|
||||
|
||||
#chat-thread-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-thread-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 80%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
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 {
|
||||
align-self: flex-start;
|
||||
background: var(--t-raised);
|
||||
color: var(--t-bright);
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.chat-bubble.out {
|
||||
align-self: flex-end;
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
border-color: var(--t-accent);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.chat-bubble-time {
|
||||
font-size: 8px;
|
||||
color: var(--t-dim);
|
||||
margin-top: 2px;
|
||||
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.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#chat-thread-input {
|
||||
flex: 1;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-border);
|
||||
color: var(--t-bright);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#chat-thread-input:focus {
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-thread-send {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
#chat-thread-send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Pick recipient view ── */
|
||||
|
||||
#chat-pick-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-pick-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
color: var(--t-bright);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-pick-row:hover {
|
||||
background: var(--t-raised);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Footer button ── */
|
||||
|
||||
#chat-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-button);
|
||||
color: var(--t-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#chat-footer-btn:hover {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-footer-btn .chat-footer-label {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#chat-footer-btn.has-unread {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
|
||||
#chat-footer-btn .chat-unread-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#chat-footer-btn.has-unread .chat-unread-dot {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes chatBlink {
|
||||
0%, 100% {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
box-shadow: 0 0 0 0 var(--t-accent);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-bright);
|
||||
box-shadow: 0 0 8px 1px var(--t-accent);
|
||||
}
|
||||
}
|
||||
|
||||
#chat-footer-btn.blinking {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- Chat plugin: bottom-right flyout. Hidden by default; toggled by the
|
||||
footer button injected via chat.js. -->
|
||||
<div id="chat-flyout" class="hidden">
|
||||
<div id="chat-flyout-header">
|
||||
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span>
|
||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" title="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
||||
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" title="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
||||
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation list (default view) -->
|
||||
<div id="chat-list-view" class="chat-view">
|
||||
<div id="chat-list" class="chat-list"></div>
|
||||
<div id="chat-empty" class="hidden chat-empty">
|
||||
<i class="ph ph-chats text-2xl text-t-dim mb-2"></i>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation thread view -->
|
||||
<div id="chat-thread-view" class="chat-view hidden">
|
||||
<div id="chat-thread-header">
|
||||
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<i class="ph ph-user-circle text-t-accent text-sm"></i>
|
||||
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
||||
</div>
|
||||
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
||||
<form id="chat-thread-form">
|
||||
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
|
||||
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" title="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New-conversation peer picker -->
|
||||
<div id="chat-pick-view" class="chat-view hidden">
|
||||
<div id="chat-pick-header">
|
||||
<button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<span class="font-pixel text-[10px] text-t-bright flex-1">pick a recipient</span>
|
||||
</div>
|
||||
<div id="chat-pick-list" class="chat-pick-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,425 @@
|
||||
// ── Chat Plugin Frontend ──
|
||||
//
|
||||
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
||||
// (faster while open, slower while closed). Blinks the footer button
|
||||
// when new incoming messages arrive while the flyout is closed.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
let conversations = [];
|
||||
let currentPeer = null;
|
||||
let currentMessages = [];
|
||||
let pollHandle = null;
|
||||
let pollIntervalMs = 20000; // closed default
|
||||
let recipientCache = null; // { tenants: [...], self: '...' }
|
||||
let initialPollDone = false;
|
||||
|
||||
// Chat polls aggressively because the endpoint is cheap (single indexed
|
||||
// SELECT with LIMIT 500). Card sync stays on its own slower interval —
|
||||
// CalDAV is the expensive one. Tab-hidden pauses the timer entirely.
|
||||
const POLL_OPEN_MS = 8000;
|
||||
const POLL_CLOSED_MS = 20000;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatChatTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const diffDays = (now - d) / 86400000;
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !$('chat-flyout')?.classList.contains('hidden');
|
||||
}
|
||||
|
||||
// ── Footer button ─────────────────────────────────────────────────
|
||||
|
||||
function injectFooterButton() {
|
||||
const footer = document.querySelector('footer');
|
||||
if (!footer) return;
|
||||
if (document.getElementById('chat-footer-btn')) return;
|
||||
|
||||
// Append next to the help button on the right side of the footer
|
||||
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
|
||||
if (!rightGroup) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'chat-footer-btn';
|
||||
btn.title = 'chat';
|
||||
btn.innerHTML = `
|
||||
<i class="ph ph-chats-circle text-sm"></i>
|
||||
<span class="chat-footer-label">chat</span>
|
||||
<span id="chat-unread-count" class="chat-unread-dot"></span>
|
||||
`;
|
||||
btn.addEventListener('click', toggleFlyout);
|
||||
rightGroup.appendChild(btn);
|
||||
}
|
||||
|
||||
function setUnreadBadge(n) {
|
||||
const btn = $('chat-footer-btn');
|
||||
if (!btn) return;
|
||||
if (n > 0) {
|
||||
btn.classList.add('has-unread');
|
||||
} else {
|
||||
btn.classList.remove('has-unread');
|
||||
btn.classList.remove('blinking');
|
||||
}
|
||||
}
|
||||
|
||||
function startBlink() {
|
||||
if (isOpen()) return;
|
||||
$('chat-footer-btn')?.classList.add('blinking');
|
||||
}
|
||||
|
||||
function stopBlink() {
|
||||
$('chat-footer-btn')?.classList.remove('blinking');
|
||||
}
|
||||
|
||||
// ── Flyout open / close ───────────────────────────────────────────
|
||||
|
||||
async function toggleFlyout() {
|
||||
if (isOpen()) closeFlyout();
|
||||
else await openFlyout();
|
||||
}
|
||||
|
||||
async function openFlyout() {
|
||||
$('chat-flyout').classList.remove('hidden');
|
||||
// Capture blink state BEFORE we clear it — it tells us a peer wrote since last close
|
||||
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
|
||||
stopBlink();
|
||||
pollIntervalMs = POLL_OPEN_MS;
|
||||
restartPoll();
|
||||
await loadConversations();
|
||||
await pollOnce(); // immediate refresh on open
|
||||
await loadConversations();
|
||||
|
||||
// Pick the right initial view:
|
||||
// 1. If blinking → newest peer with unread (= someone wrote to us)
|
||||
// 2. Else → last peer the user opened manually (persisted)
|
||||
// 3. Else → list view
|
||||
let target = null;
|
||||
if (wasBlinking) {
|
||||
const unread = conversations
|
||||
.filter(c => parseInt(c.unread) > 0)
|
||||
.sort((a, b) => String(b.last_at).localeCompare(String(a.last_at)));
|
||||
if (unread.length) target = unread[0].peer;
|
||||
}
|
||||
if (!target) {
|
||||
const lastPeer = localStorage.getItem('chat-last-peer');
|
||||
if (lastPeer && conversations.some(c => c.peer === lastPeer)) target = lastPeer;
|
||||
}
|
||||
if (target) await openConversation(target);
|
||||
else showListView();
|
||||
}
|
||||
|
||||
function closeFlyout() {
|
||||
$('chat-flyout').classList.add('hidden');
|
||||
pollIntervalMs = POLL_CLOSED_MS;
|
||||
restartPoll();
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────
|
||||
|
||||
function showListView() {
|
||||
$('chat-list-view').classList.remove('hidden');
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
currentPeer = null;
|
||||
}
|
||||
|
||||
function showThreadView(peer) {
|
||||
$('chat-list-view').classList.add('hidden');
|
||||
$('chat-thread-view').classList.remove('hidden');
|
||||
$('chat-pick-view').classList.add('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-thread-peer').textContent = peer;
|
||||
currentPeer = peer;
|
||||
}
|
||||
|
||||
function showPickView() {
|
||||
$('chat-list-view').classList.add('hidden');
|
||||
$('chat-thread-view').classList.add('hidden');
|
||||
$('chat-pick-view').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Conversation list ─────────────────────────────────────────────
|
||||
|
||||
async function loadConversations() {
|
||||
const res = await fetch('api/chat/conversations').then(r => r.json()).catch(() => null);
|
||||
if (!res) return;
|
||||
conversations = res.conversations || [];
|
||||
setUnreadBadge(res.total_unread || 0);
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
function renderConversations() {
|
||||
const list = $('chat-list');
|
||||
const empty = $('chat-empty');
|
||||
if (!list || !empty) return;
|
||||
if (!conversations.length) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
list.innerHTML = conversations.map(c => {
|
||||
const initial = (c.peer || '?').charAt(0).toUpperCase();
|
||||
const unreadBadge = parseInt(c.unread) > 0
|
||||
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
||||
const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview';
|
||||
return `
|
||||
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}">
|
||||
<div class="chat-list-avatar">${escapeHtml(initial)}</div>
|
||||
<div class="chat-list-info">
|
||||
<div class="chat-list-peer">
|
||||
<span>${escapeHtml(c.peer)}</span>
|
||||
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
||||
</div>
|
||||
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div>
|
||||
</div>
|
||||
${unreadBadge}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.chat-list-row').forEach(row => {
|
||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Thread ────────────────────────────────────────────────────────
|
||||
|
||||
async function openConversation(peer) {
|
||||
showThreadView(peer);
|
||||
localStorage.setItem('chat-last-peer', peer);
|
||||
await loadThread(peer);
|
||||
// Mark all incoming as read
|
||||
await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {});
|
||||
// Refresh badges in the background
|
||||
loadConversations();
|
||||
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
||||
}
|
||||
|
||||
async function loadThread(peer) {
|
||||
const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer))
|
||||
.then(r => r.json()).catch(() => null);
|
||||
if (!res) return;
|
||||
currentMessages = res.messages || [];
|
||||
renderThread();
|
||||
}
|
||||
|
||||
function renderThread() {
|
||||
const wrap = $('chat-thread-messages');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = currentMessages.map(m => {
|
||||
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 `
|
||||
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||
`;
|
||||
}).join('');
|
||||
// Scroll to bottom
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendMessage(e) {
|
||||
e?.preventDefault();
|
||||
const input = $('chat-thread-input');
|
||||
const body = input.value.trim();
|
||||
if (!body || !currentPeer) return;
|
||||
const sendBtn = $('chat-thread-send');
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await hyprcrd.api('api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { peer: currentPeer, body },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = [
|
||||
res.error || 'unknown',
|
||||
res.url ? `URL: ${res.url}` : null,
|
||||
res.response_body ? `Body: ${res.response_body}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'send failed',
|
||||
message: detail,
|
||||
danger: true,
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
await loadThread(currentPeer);
|
||||
await loadConversations();
|
||||
} catch (err) {
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'send error',
|
||||
message: String(err?.message || err),
|
||||
danger: true,
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
});
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pick recipient ────────────────────────────────────────────────
|
||||
|
||||
async function openPickView() {
|
||||
showPickView();
|
||||
const list = $('chat-pick-list');
|
||||
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||
|
||||
if (!recipientCache) {
|
||||
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
||||
if (!res?.ok) {
|
||||
list.innerHTML = `<div class="px-3 py-3 text-[10px] text-t-danger font-pixel">${escapeHtml(res?.error || 'failed to load recipients')}</div>`;
|
||||
return;
|
||||
}
|
||||
recipientCache = res;
|
||||
}
|
||||
|
||||
const tenants = recipientCache.tenants || [];
|
||||
const self = recipientCache.self;
|
||||
const others = tenants.filter(t => t.username !== self);
|
||||
if (!others.length) {
|
||||
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;
|
||||
}
|
||||
list.innerHTML = others.map(t => `
|
||||
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}">
|
||||
<i class="ph ph-user-circle text-sm"></i>
|
||||
<span>${escapeHtml(t.username)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
||||
row.addEventListener('click', () => openConversation(row.dataset.peer));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────
|
||||
|
||||
async function pollOnce() {
|
||||
const icon = $('chat-refresh-icon');
|
||||
if (icon && isOpen()) icon.classList.add('card-spinner');
|
||||
try {
|
||||
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
||||
if (!res?.ok) return;
|
||||
const newCount = res.new || 0;
|
||||
const receiptUpdates = res.receipts || 0;
|
||||
if (newCount > 0) {
|
||||
await loadConversations();
|
||||
// If we're inside a thread for any peer, refresh + auto-read it
|
||||
if (isOpen() && currentPeer) {
|
||||
await loadThread(currentPeer);
|
||||
hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } })
|
||||
.then(() => loadConversations()).catch(() => {});
|
||||
}
|
||||
// Blink if the flyout is closed
|
||||
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) {
|
||||
// First poll: refresh badges in case we have unread from a previous session
|
||||
await loadConversations();
|
||||
}
|
||||
initialPollDone = true;
|
||||
} finally {
|
||||
if (icon) icon.classList.remove('card-spinner');
|
||||
}
|
||||
}
|
||||
|
||||
function restartPoll() {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
pollHandle = setInterval(pollOnce, pollIntervalMs);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
if (!$('chat-flyout')) return;
|
||||
|
||||
injectFooterButton();
|
||||
|
||||
$('chat-close-btn').addEventListener('click', closeFlyout);
|
||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||
$('chat-new-btn').addEventListener('click', openPickView);
|
||||
$('chat-empty-start')?.addEventListener('click', openPickView);
|
||||
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
||||
$('chat-pick-back').addEventListener('click', showListView);
|
||||
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||
|
||||
// Esc closes flyout when focus is inside it
|
||||
$('chat-flyout').addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
||||
});
|
||||
|
||||
// Defer the first POST (poll) until auth has set csrf. GETs are fine immediately.
|
||||
loadConversations().catch(() => {});
|
||||
const startPolling = () => {
|
||||
pollOnce().catch(() => {});
|
||||
restartPoll();
|
||||
};
|
||||
if (hyprcrd.util.csrf?.()) {
|
||||
startPolling();
|
||||
} else {
|
||||
hyprcrd.hooks.on('csrf.ready', startPolling);
|
||||
}
|
||||
|
||||
// Pause polling when the page is hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||
} else {
|
||||
pollOnce().catch(() => {});
|
||||
restartPoll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'chat',
|
||||
version: '1.0.0',
|
||||
description: 'Encrypted chat between relay tenants',
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "chat",
|
||||
"version": "1.0.0",
|
||||
"description": "Encrypted chat between relay tenants. Footer flyout that blinks on new messages.",
|
||||
"frontend": {
|
||||
"js": ["chat.js"],
|
||||
"css": ["chat.css"],
|
||||
"html": ["chat.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Minimal pure-PHP IMAP4rev1 client.
|
||||
*
|
||||
* Supports:
|
||||
* - SSL/TLS connections (imaps://, port 993)
|
||||
* - LOGIN, SELECT, UID SEARCH, UID FETCH, LOGOUT
|
||||
* - MIME message body extraction (text/plain preferred, HTML fallback)
|
||||
* - Quoted-printable + base64 decoding
|
||||
* - MIME-encoded header decoding (=?utf-8?B?...?=)
|
||||
*
|
||||
* NOT supported (deferred for MVP): STARTTLS, IDLE, threads, attachments
|
||||
* download, server-side search, multiple folders.
|
||||
*
|
||||
* Errors throw RuntimeException with a friendly message.
|
||||
*/
|
||||
class ImapClient
|
||||
{
|
||||
private $sock = null;
|
||||
private int $tag = 0;
|
||||
private string $host;
|
||||
private int $port;
|
||||
private string $encryption; // 'ssl' | 'tls' | 'none'
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(string $host, int $port = 993, string $encryption = 'ssl', int $timeout = 15)
|
||||
{
|
||||
$this->host = $host;
|
||||
$this->port = $port;
|
||||
$this->encryption = $encryption;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
|
||||
$url = $scheme . '://' . $this->host . ':' . $this->port;
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'allow_self_signed' => false,
|
||||
],
|
||||
]);
|
||||
$errno = 0; $errstr = '';
|
||||
$this->sock = @stream_socket_client($url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||
if (!$this->sock) {
|
||||
throw new RuntimeException("Connect failed: $errstr ($errno)");
|
||||
}
|
||||
stream_set_timeout($this->sock, $this->timeout);
|
||||
// Read greeting
|
||||
$greet = $this->readLine();
|
||||
if (strpos($greet, '* OK') !== 0 && strpos($greet, '* PREAUTH') !== 0) {
|
||||
throw new RuntimeException('Bad server greeting: ' . trim($greet));
|
||||
}
|
||||
}
|
||||
|
||||
public function login(string $user, string $pass): void
|
||||
{
|
||||
// Quote string literals to be safe
|
||||
$user = $this->quoteString($user);
|
||||
$pass = $this->quoteString($pass);
|
||||
$resp = $this->command("LOGIN $user $pass");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('LOGIN failed: ' . $resp['text']);
|
||||
}
|
||||
}
|
||||
|
||||
public function selectFolder(string $folder = 'INBOX'): array
|
||||
{
|
||||
$folder = $this->quoteString($folder);
|
||||
$resp = $this->command("SELECT $folder");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('SELECT failed: ' . $resp['text']);
|
||||
}
|
||||
// Parse EXISTS count from untagged responses
|
||||
$exists = 0;
|
||||
foreach ($resp['untagged'] as $line) {
|
||||
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
|
||||
$exists = (int)$m[1];
|
||||
}
|
||||
}
|
||||
return ['exists' => $exists];
|
||||
}
|
||||
|
||||
/** Search returns UIDs of all messages matching criteria. */
|
||||
public function uidSearch(string $criteria = 'ALL'): array
|
||||
{
|
||||
$resp = $this->command("UID SEARCH $criteria");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('SEARCH failed: ' . $resp['text']);
|
||||
}
|
||||
$uids = [];
|
||||
foreach ($resp['untagged'] as $line) {
|
||||
if (preg_match('/^\* SEARCH(.*)$/i', $line, $m)) {
|
||||
preg_match_all('/\d+/', $m[1], $nums);
|
||||
foreach ($nums[0] as $n) $uids[] = (int)$n;
|
||||
}
|
||||
}
|
||||
return $uids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch envelope-like headers + spam-relevant fields for a list of UIDs.
|
||||
* Returns [{uid, subject, from, date, seen, headers: <full-parsed-map>}, ...]
|
||||
* The `headers` map contains lowercased keys ready for SpamDetector.
|
||||
*/
|
||||
public function fetchHeaders(array $uids): array
|
||||
{
|
||||
if (!$uids) return [];
|
||||
sort($uids);
|
||||
$set = $this->compactUidSet($uids);
|
||||
// Pull more fields so SpamDetector has signals to work with
|
||||
$fields = 'FROM SUBJECT DATE REPLY-TO MESSAGE-ID '
|
||||
. 'X-SPAM-FLAG X-SPAM-STATUS X-SPAM-SCORE '
|
||||
. 'AUTHENTICATION-RESULTS RECEIVED-SPF '
|
||||
. 'LIST-UNSUBSCRIBE';
|
||||
$resp = $this->command("UID FETCH $set (UID FLAGS BODY[HEADER.FIELDS ($fields)])");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('FETCH headers failed: ' . $resp['text']);
|
||||
}
|
||||
$messages = [];
|
||||
foreach ($resp['fetches'] as $entry) {
|
||||
$msg = [
|
||||
'uid' => null,
|
||||
'subject' => '',
|
||||
'from' => '',
|
||||
'date' => '',
|
||||
'seen' => false,
|
||||
'headers' => [],
|
||||
];
|
||||
if (preg_match('/UID (\d+)/', $entry['preamble'], $m)) $msg['uid'] = (int)$m[1];
|
||||
if (preg_match('/FLAGS \(([^)]*)\)/', $entry['preamble'], $m)) {
|
||||
$msg['seen'] = stripos($m[1], '\\Seen') !== false;
|
||||
}
|
||||
$headers = $this->parseHeaders($entry['body']);
|
||||
// Decode MIME-encoded fields used by the UI
|
||||
$msg['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||
$msg['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||
$msg['date'] = $headers['date'] ?? '';
|
||||
// Pass the decoded versions back into the header map so SpamDetector
|
||||
// sees the same strings the user sees
|
||||
$headers['subject'] = $msg['subject'];
|
||||
$headers['from'] = $msg['from'];
|
||||
if (!empty($headers['reply-to'])) {
|
||||
$headers['reply-to'] = $this->decodeMimeHeader($headers['reply-to']);
|
||||
}
|
||||
$msg['headers'] = $headers;
|
||||
if ($msg['uid']) $messages[] = $msg;
|
||||
}
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full message body and parse it.
|
||||
* Returns ['headers' => [...], 'text' => '...', 'html' => '...', 'attachments' => [...]]
|
||||
*/
|
||||
public function fetchMessage(int $uid): array
|
||||
{
|
||||
$resp = $this->command("UID FETCH $uid (UID BODY.PEEK[])");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('FETCH body failed: ' . $resp['text']);
|
||||
}
|
||||
if (!$resp['fetches']) {
|
||||
throw new RuntimeException('Message not found');
|
||||
}
|
||||
$raw = $resp['fetches'][0]['body'];
|
||||
return $this->parseMessage($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a list of UIDs as deleted (\Deleted flag) and expunge them.
|
||||
* After expunge, those messages are gone from the server permanently.
|
||||
* Returns the number of UIDs that were processed.
|
||||
*/
|
||||
public function deleteUids(array $uids): int
|
||||
{
|
||||
if (!$uids) return 0;
|
||||
$set = $this->compactUidSet($uids);
|
||||
$resp = $this->command("UID STORE $set +FLAGS (\\Deleted)");
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('STORE \\Deleted failed: ' . $resp['text']);
|
||||
}
|
||||
$resp = $this->command('EXPUNGE');
|
||||
if (!$resp['ok']) {
|
||||
throw new RuntimeException('EXPUNGE failed: ' . $resp['text']);
|
||||
}
|
||||
return count($uids);
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
if ($this->sock) {
|
||||
try { @$this->command('LOGOUT'); } catch (\Throwable $e) {}
|
||||
@fclose($this->sock);
|
||||
$this->sock = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->logout();
|
||||
}
|
||||
|
||||
// ── Low-level protocol ─────────────────────────────────────────
|
||||
|
||||
private function nextTag(): string
|
||||
{
|
||||
$this->tag++;
|
||||
return 'A' . str_pad((string)$this->tag, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function writeLine(string $line): void
|
||||
{
|
||||
if (fwrite($this->sock, $line . "\r\n") === false) {
|
||||
throw new RuntimeException('Socket write failed');
|
||||
}
|
||||
}
|
||||
|
||||
private function readLine(): string
|
||||
{
|
||||
$line = fgets($this->sock);
|
||||
if ($line === false) {
|
||||
throw new RuntimeException('Socket read failed (timeout?)');
|
||||
}
|
||||
return $line;
|
||||
}
|
||||
|
||||
private function readBytes(int $n): string
|
||||
{
|
||||
$buf = '';
|
||||
while (strlen($buf) < $n) {
|
||||
$chunk = fread($this->sock, $n - strlen($buf));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
throw new RuntimeException('Socket read truncated');
|
||||
}
|
||||
$buf .= $chunk;
|
||||
}
|
||||
return $buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command and read all response lines until tagged completion.
|
||||
* Handles literal continuations ({N}\r\n followed by N bytes).
|
||||
* Returns ['ok' => bool, 'text' => string, 'untagged' => [...], 'fetches' => [...]]
|
||||
*/
|
||||
private function command(string $cmd): array
|
||||
{
|
||||
$tag = $this->nextTag();
|
||||
$this->writeLine("$tag $cmd");
|
||||
|
||||
$untagged = [];
|
||||
$fetches = []; // [{preamble, body}]
|
||||
$okResult = null;
|
||||
$okText = '';
|
||||
|
||||
while (true) {
|
||||
$line = $this->readLine();
|
||||
// Detect literal: {N}\r\n at end → read N more bytes, then continue
|
||||
$literalBuf = '';
|
||||
while (preg_match('/\{(\d+)\}\r?\n$/', $line, $m)) {
|
||||
$bytes = (int)$m[1];
|
||||
$literalBuf .= $this->readBytes($bytes);
|
||||
$next = $this->readLine();
|
||||
$line = rtrim($line, "\r\n") . "\x00LIT\x00" . $literalBuf . "\x00LITEND\x00" . $next;
|
||||
$literalBuf = '';
|
||||
}
|
||||
|
||||
// Tagged response check
|
||||
if (strpos($line, $tag . ' ') === 0) {
|
||||
$rest = substr($line, strlen($tag) + 1);
|
||||
$okResult = (strpos($rest, 'OK') === 0);
|
||||
$okText = trim($rest);
|
||||
break;
|
||||
}
|
||||
|
||||
// Untagged
|
||||
if (strpos($line, '* ') === 0) {
|
||||
$untagged[] = trim($line);
|
||||
// Detect FETCH responses with literal body
|
||||
if (preg_match('/^\* \d+ FETCH /', $line)) {
|
||||
if (preg_match('/\x00LIT\x00(.*?)\x00LITEND\x00/s', $line, $m)) {
|
||||
$body = $m[1];
|
||||
$preamble = preg_replace('/\x00LIT\x00.*?\x00LITEND\x00.*$/s', '', $line);
|
||||
$fetches[] = ['preamble' => $preamble, 'body' => $body];
|
||||
} else {
|
||||
// No literal, body might be inline (small messages)
|
||||
$fetches[] = ['preamble' => $line, 'body' => ''];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => $okResult, 'text' => $okText, 'untagged' => $untagged, 'fetches' => $fetches];
|
||||
}
|
||||
|
||||
private function quoteString(string $s): string
|
||||
{
|
||||
// Simple quoting; passwords with quotes are escaped
|
||||
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $s) . '"';
|
||||
}
|
||||
|
||||
private function compactUidSet(array $uids): string
|
||||
{
|
||||
// For simplicity, just send a comma list (most servers accept this).
|
||||
// Could be optimized to ranges (1:5,10,15:20).
|
||||
return implode(',', $uids);
|
||||
}
|
||||
|
||||
// ── MIME parsing ───────────────────────────────────────────────
|
||||
|
||||
private function parseHeaders(string $raw): array
|
||||
{
|
||||
$headers = [];
|
||||
$lines = preg_split('/\r?\n/', $raw);
|
||||
$current = null;
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') break;
|
||||
if (preg_match('/^([!-9;-~]+):\s*(.*)$/', $line, $m)) {
|
||||
$current = strtolower($m[1]);
|
||||
$headers[$current] = $m[2];
|
||||
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
|
||||
// Continuation
|
||||
$headers[$current] .= ' ' . trim($line);
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function decodeMimeHeader(string $value): string
|
||||
{
|
||||
// Decode =?charset?B?...?= or =?charset?Q?...?=
|
||||
return preg_replace_callback(
|
||||
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
|
||||
function ($m) {
|
||||
$charset = strtoupper($m[1]);
|
||||
$enc = strtoupper($m[2]);
|
||||
$data = $m[3];
|
||||
if ($enc === 'B') {
|
||||
$decoded = base64_decode($data);
|
||||
} else { // Q
|
||||
$decoded = quoted_printable_decode(str_replace('_', ' ', $data));
|
||||
}
|
||||
if ($charset !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||
}
|
||||
return $decoded;
|
||||
},
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full RFC 822 message into headers + plain/html bodies.
|
||||
* Returns ['headers' => [...], 'text' => string, 'html' => string]
|
||||
*/
|
||||
public function parseMessage(string $raw): array
|
||||
{
|
||||
// Split header / body at first blank line
|
||||
$pos = strpos($raw, "\r\n\r\n");
|
||||
if ($pos === false) $pos = strpos($raw, "\n\n");
|
||||
$headerRaw = $pos !== false ? substr($raw, 0, $pos) : $raw;
|
||||
$bodyRaw = $pos !== false ? substr($raw, $pos + ($raw[$pos] === "\r" ? 4 : 2)) : '';
|
||||
|
||||
$headers = $this->parseHeaders($headerRaw);
|
||||
$headers['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||
$headers['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||
$headers['to'] = $this->decodeMimeHeader($headers['to'] ?? '');
|
||||
|
||||
$contentType = $headers['content-type'] ?? 'text/plain';
|
||||
$encoding = strtolower($headers['content-transfer-encoding'] ?? '7bit');
|
||||
|
||||
$text = '';
|
||||
$html = '';
|
||||
|
||||
if (stripos($contentType, 'multipart/') === 0) {
|
||||
$boundary = $this->extractBoundary($contentType);
|
||||
if ($boundary) {
|
||||
[$text, $html] = $this->parseMultipart($bodyRaw, $boundary);
|
||||
}
|
||||
} else {
|
||||
$decoded = $this->decodeBody($bodyRaw, $encoding);
|
||||
$charset = $this->extractCharset($contentType);
|
||||
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||
}
|
||||
if (stripos($contentType, 'text/html') !== false) {
|
||||
$html = $decoded;
|
||||
} else {
|
||||
$text = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return ['headers' => $headers, 'text' => $text, 'html' => $html];
|
||||
}
|
||||
|
||||
private function parseMultipart(string $body, string $boundary): array
|
||||
{
|
||||
$parts = explode('--' . $boundary, $body);
|
||||
// First chunk before first boundary, last chunk has -- closing — discard
|
||||
array_shift($parts);
|
||||
array_pop($parts);
|
||||
|
||||
$text = '';
|
||||
$html = '';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = ltrim($part, "\r\n");
|
||||
$pos = strpos($part, "\r\n\r\n");
|
||||
if ($pos === false) $pos = strpos($part, "\n\n");
|
||||
if ($pos === false) continue;
|
||||
$partHeaderRaw = substr($part, 0, $pos);
|
||||
$partBody = substr($part, $pos + ($part[$pos] === "\r" ? 4 : 2));
|
||||
$partHeaders = $this->parseHeaders($partHeaderRaw);
|
||||
$ct = $partHeaders['content-type'] ?? 'text/plain';
|
||||
$cte = strtolower($partHeaders['content-transfer-encoding'] ?? '7bit');
|
||||
|
||||
if (stripos($ct, 'multipart/') === 0) {
|
||||
$innerBoundary = $this->extractBoundary($ct);
|
||||
if ($innerBoundary) {
|
||||
[$nestedText, $nestedHtml] = $this->parseMultipart($partBody, $innerBoundary);
|
||||
if ($nestedText && !$text) $text = $nestedText;
|
||||
if ($nestedHtml && !$html) $html = $nestedHtml;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeBody($partBody, $cte);
|
||||
$charset = $this->extractCharset($ct);
|
||||
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||
}
|
||||
|
||||
if (stripos($ct, 'text/plain') !== false && !$text) {
|
||||
$text = $decoded;
|
||||
} elseif (stripos($ct, 'text/html') !== false && !$html) {
|
||||
$html = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return [$text, $html];
|
||||
}
|
||||
|
||||
private function decodeBody(string $body, string $encoding): string
|
||||
{
|
||||
switch ($encoding) {
|
||||
case 'base64':
|
||||
return base64_decode(preg_replace('/\s+/', '', $body)) ?: '';
|
||||
case 'quoted-printable':
|
||||
return quoted_printable_decode($body);
|
||||
default:
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
|
||||
private function extractBoundary(string $contentType): ?string
|
||||
{
|
||||
if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractCharset(string $contentType): ?string
|
||||
{
|
||||
if (preg_match('/charset\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SpamDetector — multi-signal heuristic spam scoring.
|
||||
*
|
||||
* Composes signals from:
|
||||
* - Upstream filter results (X-Spam-Flag, X-Spam-Status, X-Spam-Score)
|
||||
* - Authentication-Results / Received-SPF (SPF/DKIM/DMARC pass/fail)
|
||||
* - Subject heuristics (ALL CAPS, excess punctuation, spam keywords EN+DE)
|
||||
* - Sender heuristics (suspicious TLDs, digit-heavy domains, lookalikes,
|
||||
* display name mismatch, missing/mismatched Reply-To)
|
||||
* - Optional body heuristics (link density, short URLs, currency spam)
|
||||
*
|
||||
* Scoring:
|
||||
* 0–3 → clean (no marking)
|
||||
* 4–7 → suspicious (yellow)
|
||||
* 8+ → spam (red)
|
||||
*/
|
||||
class SpamDetector
|
||||
{
|
||||
/** User-defined block rules: sender emails (lowercase). */
|
||||
private array $blockEmails = [];
|
||||
/** User-defined block rules: sender domains (lowercase). */
|
||||
private array $blockDomains = [];
|
||||
/** User-defined allow rules. */
|
||||
private array $allowEmails = [];
|
||||
private array $allowDomains = [];
|
||||
|
||||
/**
|
||||
* @param array $rules Optional array of rule rows from email_spam_rules:
|
||||
* [{rule_type, sender_email, sender_domain}, ...]
|
||||
*/
|
||||
public function __construct(array $rules = [])
|
||||
{
|
||||
foreach ($rules as $r) {
|
||||
$type = $r['rule_type'] ?? '';
|
||||
$email = strtolower(trim((string)($r['sender_email'] ?? '')));
|
||||
$domain = strtolower(trim((string)($r['sender_domain'] ?? '')));
|
||||
if ($type === 'block') {
|
||||
if ($email) $this->blockEmails[] = $email;
|
||||
if ($domain) $this->blockDomains[] = $domain;
|
||||
} elseif ($type === 'allow') {
|
||||
if ($email) $this->allowEmails[] = $email;
|
||||
if ($domain) $this->allowDomains[] = $domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** English + German spam keywords. Each match adds 2 points (capped). */
|
||||
private const SPAM_PHRASES = [
|
||||
// English
|
||||
'viagra', 'cialis', 'pharmacy', 'rolex', 'casino', 'lottery', 'winner',
|
||||
'congratulations', 'claim your prize', 'act now', 'click here', 'free gift',
|
||||
'limited time', 'risk free', 'no obligation', 'satisfaction guaranteed',
|
||||
'money back', 'lose weight', 'work from home', 'make money fast',
|
||||
'earn extra cash', 'inheritance', 'beneficiary', 'next of kin',
|
||||
'crypto millionaire', '100% free', '100% satisfied', 'urgent reply',
|
||||
'wire transfer', 'western union', 'nigerian prince', 'unclaimed funds',
|
||||
'verify your account', 'suspended account', 'confirm your identity',
|
||||
// German
|
||||
'sie haben gewonnen', 'gewinnspiel', 'lotto', 'erbschaft', 'sofort gewinn',
|
||||
'kostenlos', 'gratis', 'nur heute', 'einmalig', 'jetzt zugreifen',
|
||||
'jetzt bestellen', 'risikofrei', 'geld verdienen', 'schnell reich',
|
||||
'abnehmen ohne', 'wundermittel', 'klicken sie hier', 'bestätigen sie',
|
||||
'ihr konto wurde', 'ihr passwort', 'verifizieren sie', 'dringend antworten',
|
||||
];
|
||||
|
||||
/** Suspicious TLDs frequently used by spammers. */
|
||||
private const SUSPICIOUS_TLDS = [
|
||||
'top', 'xyz', 'click', 'link', 'work', 'gq', 'ml', 'tk', 'ga', 'cf',
|
||||
'loan', 'racing', 'win', 'download', 'review', 'country', 'kim',
|
||||
'science', 'party', 'bid', 'trade', 'date', 'stream', 'cricket',
|
||||
'webcam', 'mom', 'rest',
|
||||
];
|
||||
|
||||
/** URL shorteners (high spam correlation in cold email). */
|
||||
private const SHORT_URL_HOSTS = [
|
||||
'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly',
|
||||
'tiny.cc', 'cutt.ly', 'shorturl.at', 'rebrand.ly', 'rb.gy',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array $headers Lowercase-keyed header map (as returned by ImapClient::parseHeaders)
|
||||
* @param string|null $bodyText Optional plaintext body for content checks
|
||||
* @return array{score:int, level:string, reasons:array<string>}
|
||||
*/
|
||||
public function score(array $headers, ?string $bodyText = null): array
|
||||
{
|
||||
$score = 0;
|
||||
$reasons = [];
|
||||
|
||||
// ── User-defined rules (override the heuristic with strong weight) ──
|
||||
$fromAddrEarly = $this->extractAddress($headers['from'] ?? '');
|
||||
$fromDomainEarly = strtolower($this->extractDomain($fromAddrEarly));
|
||||
$fromAddrLow = strtolower($fromAddrEarly);
|
||||
|
||||
if ($fromAddrLow && in_array($fromAddrLow, $this->allowEmails, true)) {
|
||||
$score -= 15;
|
||||
$reasons[] = 'sender on allowlist (email)';
|
||||
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->allowDomains, true)) {
|
||||
$score -= 12;
|
||||
$reasons[] = 'sender domain on allowlist';
|
||||
}
|
||||
|
||||
if ($fromAddrLow && in_array($fromAddrLow, $this->blockEmails, true)) {
|
||||
$score += 15;
|
||||
$reasons[] = 'sender on blocklist (email)';
|
||||
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->blockDomains, true)) {
|
||||
$score += 12;
|
||||
$reasons[] = 'sender domain on blocklist';
|
||||
}
|
||||
|
||||
// ── Upstream filter verdict (strongest signal) ──────────────
|
||||
|
||||
$xSpamFlag = strtolower(trim($headers['x-spam-flag'] ?? ''));
|
||||
if ($xSpamFlag === 'yes') {
|
||||
$score += 10;
|
||||
$reasons[] = 'upstream X-Spam-Flag: YES';
|
||||
}
|
||||
|
||||
$xSpamStatus = $headers['x-spam-status'] ?? '';
|
||||
if (preg_match('/^Yes/i', $xSpamStatus)) {
|
||||
$score += 8;
|
||||
$reasons[] = 'upstream X-Spam-Status: Yes';
|
||||
}
|
||||
if (preg_match('/score\s*=\s*([\d.]+)/i', $xSpamStatus, $m)) {
|
||||
$sa = (float)$m[1];
|
||||
if ($sa >= 5) {
|
||||
$score += 5;
|
||||
$reasons[] = "SpamAssassin score $sa";
|
||||
} elseif ($sa >= 3) {
|
||||
$score += 2;
|
||||
$reasons[] = "SpamAssassin score $sa";
|
||||
}
|
||||
}
|
||||
|
||||
$xSpamScore = $headers['x-spam-score'] ?? '';
|
||||
if (is_numeric(trim($xSpamScore)) && (float)$xSpamScore >= 5) {
|
||||
$score += 4;
|
||||
$reasons[] = "X-Spam-Score $xSpamScore";
|
||||
}
|
||||
|
||||
// ── Authentication results (SPF/DKIM/DMARC) ─────────────────
|
||||
|
||||
$authResults = strtolower($headers['authentication-results'] ?? '');
|
||||
$receivedSpf = strtolower($headers['received-spf'] ?? '');
|
||||
|
||||
if (str_contains($authResults, 'spf=fail') || str_starts_with($receivedSpf, 'fail')) {
|
||||
$score += 4;
|
||||
$reasons[] = 'SPF fail';
|
||||
} elseif (str_contains($authResults, 'spf=softfail') || str_starts_with($receivedSpf, 'softfail')) {
|
||||
$score += 1;
|
||||
$reasons[] = 'SPF softfail';
|
||||
}
|
||||
|
||||
if (str_contains($authResults, 'dkim=fail')) {
|
||||
$score += 3;
|
||||
$reasons[] = 'DKIM fail';
|
||||
}
|
||||
|
||||
if (str_contains($authResults, 'dmarc=fail')) {
|
||||
$score += 4;
|
||||
$reasons[] = 'DMARC fail';
|
||||
}
|
||||
|
||||
// Missing Message-ID is rare in legitimate mail
|
||||
if (empty($headers['message-id']) && empty($headers['x-spam-flag'])) {
|
||||
$score += 2;
|
||||
$reasons[] = 'missing Message-ID';
|
||||
}
|
||||
|
||||
// ── Subject heuristics ──────────────────────────────────────
|
||||
|
||||
$subject = $headers['subject'] ?? '';
|
||||
if ($subject !== '') {
|
||||
// ALL CAPS check (only count letters; ignore short subjects)
|
||||
$letters = preg_replace('/[^A-Za-zÄÖÜäöüß]/u', '', $subject);
|
||||
if (strlen($letters) >= 12) {
|
||||
$upper = preg_replace('/[^A-ZÄÖÜ]/u', '', $letters);
|
||||
$ratio = strlen($upper) / max(1, strlen($letters));
|
||||
if ($ratio > 0.7) {
|
||||
$score += 3;
|
||||
$reasons[] = 'ALL CAPS subject';
|
||||
}
|
||||
}
|
||||
|
||||
// Excessive exclamation/question marks
|
||||
$excessPunct = preg_match_all('/[!?]{2,}/', $subject);
|
||||
if ($excessPunct >= 1) {
|
||||
$score += min(3, $excessPunct + 1);
|
||||
$reasons[] = 'excessive punctuation';
|
||||
}
|
||||
|
||||
// Currency clusters
|
||||
if (preg_match_all('/[\$€£¥]\s*\d+|\d+\s*[\$€£¥]|EUR\s*\d+|USD\s*\d+/i', $subject, $cm)) {
|
||||
if (count($cm[0]) >= 1) {
|
||||
$score += 2;
|
||||
$reasons[] = 'currency in subject';
|
||||
}
|
||||
}
|
||||
|
||||
// Keyword matches (capped at +6)
|
||||
$kwScore = 0;
|
||||
$kwHits = 0;
|
||||
$lowSubject = function_exists('mb_strtolower') ? mb_strtolower($subject) : strtolower($subject);
|
||||
foreach (self::SPAM_PHRASES as $phrase) {
|
||||
if (str_contains($lowSubject, $phrase)) {
|
||||
$kwScore += 2;
|
||||
$kwHits++;
|
||||
if ($kwHits >= 3) break; // cap at 3 distinct hits
|
||||
}
|
||||
}
|
||||
if ($kwScore > 0) {
|
||||
$score += min(6, $kwScore);
|
||||
$reasons[] = "$kwHits spam keyword" . ($kwHits === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
// Cyrillic homoglyph attack: Latin recipients getting Cyrillic letters
|
||||
if (preg_match('/[\x{0400}-\x{04FF}]/u', $subject)
|
||||
&& preg_match('/[a-zA-Z]/', $subject)) {
|
||||
$score += 4;
|
||||
$reasons[] = 'mixed Latin/Cyrillic (homoglyph)';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sender heuristics ───────────────────────────────────────
|
||||
|
||||
$from = $headers['from'] ?? '';
|
||||
$replyTo = $headers['reply-to'] ?? '';
|
||||
|
||||
$fromAddr = $this->extractAddress($from);
|
||||
$replyAddr = $this->extractAddress($replyTo);
|
||||
$fromDomain = $this->extractDomain($fromAddr);
|
||||
$replyDomain = $this->extractDomain($replyAddr);
|
||||
|
||||
// Reply-To pointing to a different domain than From (classic phishing)
|
||||
if ($replyDomain && $fromDomain && strcasecmp($replyDomain, $fromDomain) !== 0) {
|
||||
$score += 3;
|
||||
$reasons[] = "Reply-To domain mismatch ($replyDomain ≠ $fromDomain)";
|
||||
}
|
||||
|
||||
// Suspicious TLD
|
||||
if ($fromDomain) {
|
||||
$tld = strtolower((string)substr(strrchr($fromDomain, '.'), 1));
|
||||
if (in_array($tld, self::SUSPICIOUS_TLDS, true)) {
|
||||
$score += 3;
|
||||
$reasons[] = "suspicious TLD .$tld";
|
||||
}
|
||||
|
||||
// Digit-heavy domain (often throwaways like x4892jk-shop.com)
|
||||
$digitCount = preg_match_all('/\d/', $fromDomain);
|
||||
$domainLen = strlen($fromDomain);
|
||||
if ($domainLen > 8 && $digitCount / $domainLen > 0.3) {
|
||||
$score += 2;
|
||||
$reasons[] = 'digit-heavy sender domain';
|
||||
}
|
||||
|
||||
// Hyphen overload
|
||||
if (substr_count($fromDomain, '-') >= 3) {
|
||||
$score += 1;
|
||||
$reasons[] = 'many hyphens in domain';
|
||||
}
|
||||
}
|
||||
|
||||
// Display name impersonates an email address that doesn't match the actual address
|
||||
if (preg_match('/^"?([^"<]+)"?\s*<([^>]+)>$/', $from, $fm)) {
|
||||
$displayName = trim($fm[1]);
|
||||
$actualAddr = trim($fm[2]);
|
||||
if (preg_match('/[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $displayName, $dm)) {
|
||||
if (strcasecmp($dm[0], $actualAddr) !== 0) {
|
||||
$score += 4;
|
||||
$reasons[] = 'display name impersonates a different email';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── List-Unsubscribe present is a slight signal it's bulk
|
||||
// (not necessarily spam, just a small positive bump)
|
||||
if (!empty($headers['list-unsubscribe'])) {
|
||||
$score += 1;
|
||||
$reasons[] = 'bulk mail (list-unsubscribe)';
|
||||
}
|
||||
|
||||
// ── Body heuristics (only if body is provided) ──────────────
|
||||
|
||||
if ($bodyText) {
|
||||
// Count URLs
|
||||
preg_match_all('#https?://([^\s<>"\']+)#i', $bodyText, $urlMatches);
|
||||
$urls = $urlMatches[1] ?? [];
|
||||
$urlCount = count($urls);
|
||||
if ($urlCount > 8) {
|
||||
$score += min(4, intdiv($urlCount, 4));
|
||||
$reasons[] = "$urlCount links in body";
|
||||
}
|
||||
|
||||
$shortCount = 0;
|
||||
foreach ($urls as $u) {
|
||||
$host = strtolower(parse_url('http://' . $u, PHP_URL_HOST) ?? '');
|
||||
if ($host && in_array($host, self::SHORT_URL_HOSTS, true)) $shortCount++;
|
||||
}
|
||||
if ($shortCount >= 2) {
|
||||
$score += 2;
|
||||
$reasons[] = "$shortCount short URLs";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Determine level ─────────────────────────────────────────
|
||||
|
||||
if ($score >= 8) {
|
||||
$level = 'spam';
|
||||
} elseif ($score >= 4) {
|
||||
$level = 'suspicious';
|
||||
} else {
|
||||
$level = 'clean';
|
||||
}
|
||||
|
||||
return ['score' => $score, 'level' => $level, 'reasons' => $reasons];
|
||||
}
|
||||
|
||||
private function extractAddress(string $field): string
|
||||
{
|
||||
if (preg_match('/<([^>]+)>/', $field, $m)) return trim($m[1]);
|
||||
if (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $field, $m)) return $m[0];
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractDomain(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return $at === false ? '' : strtolower(substr($address, $at + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# email plugin — deferred features
|
||||
|
||||
> **Note:** Account management lives in the main settings overlay under
|
||||
> "mail" (added via `hyprcrd.settings.registerTab`). The email overlay
|
||||
> shows a welcome screen with a "configure accounts" button when no
|
||||
> accounts are set up — that button switches to the settings tab.
|
||||
|
||||
|
||||
|
||||
This is the MVP scope. The following are intentionally not implemented yet
|
||||
and can be added later as plugin-only changes (no core touch needed):
|
||||
|
||||
## Protocol
|
||||
|
||||
- [ ] **SMTP / sending** — currently read-only. Add SmtpClient.php + a
|
||||
compose view in the overlay. Reuse account credentials (most providers
|
||||
use the same login for IMAP + SMTP, just different host/port).
|
||||
- [ ] **STARTTLS on port 143** — only direct SSL on 993 works today. Need to
|
||||
send `STLS` command after the greeting and wrap the socket via
|
||||
`stream_socket_enable_crypto`.
|
||||
- [ ] **IDLE / push** — currently each `loadMessages()` is a fresh poll. IDLE
|
||||
would let the server notify on new mail. Tricky with PHP request model;
|
||||
probably needs a small daemon or long-poll endpoint.
|
||||
- [ ] **OAuth2 (XOAUTH2)** — Gmail/O365 require OAuth instead of app
|
||||
passwords for full functionality. Big chunk of work: token exchange,
|
||||
refresh, storage.
|
||||
|
||||
## Folders & search
|
||||
|
||||
- [ ] **Multiple folders** — schema has `folder` column but UI exposes only
|
||||
one. Add folder list pane (like Thunderbird's left sidebar) using
|
||||
`LIST "" *`.
|
||||
- [ ] **Server-side search** — `UID SEARCH FROM "..."`, `SUBJECT "..."`,
|
||||
`SINCE date`, etc. Currently the UI shows the latest 30 only.
|
||||
- [ ] **Threading** — group messages by `In-Reply-To` / `References` headers.
|
||||
- [ ] **Mark as read / unread** — `UID STORE +FLAGS (\Seen)`.
|
||||
- [ ] **Move to folder** — `UID COPY <folder>` + delete from current.
|
||||
- [x] **Delete on server** — implemented: multi-select checkboxes in list pane,
|
||||
"delete" button in selection bar, calls `UID STORE +FLAGS (\Deleted)`
|
||||
+ `EXPUNGE` (see `ImapClient::deleteUids()`).
|
||||
|
||||
## Body & attachments
|
||||
|
||||
- [x] **HTML email rendering** — implemented: opt-in "view as html" button per
|
||||
message renders the HTML in a sandboxed iframe (srcdoc + DOMPurify).
|
||||
External images are blocked by default; "load images (N)" button reveals
|
||||
them on demand. CSS background-image url() declarations are also stripped
|
||||
from inline styles. cid: refs aren't loaded (would need a separate route).
|
||||
- [ ] **Inline images** — `cid:` references inside HTML need to fetch
|
||||
`multipart/related` parts and serve them via a data: URL.
|
||||
- [ ] **Attachments list + download** — parse `Content-Disposition: attachment`
|
||||
parts, expose them in the message header bar, route through a backend
|
||||
endpoint that streams the decoded part to the browser.
|
||||
|
||||
## UI / UX
|
||||
|
||||
- [ ] **Pagination** — currently hard limit of 30 newest messages.
|
||||
- [ ] **Per-account folder + signature settings** — extend the form.
|
||||
- [ ] **Notification on new mail** — needs IDLE or polling daemon.
|
||||
- [ ] **Drag-extract** — drag a message onto the board to create a card at
|
||||
that position.
|
||||
- [ ] **Reply / forward** — needs SMTP first.
|
||||
- [ ] **Address book / autocomplete** — pull historic sender addresses.
|
||||
|
||||
## Spam detection (current state)
|
||||
|
||||
- [x] **Multi-signal heuristic scorer** (`SpamDetector.php`):
|
||||
header signals (X-Spam-*, SPF/DKIM/DMARC), subject heuristics
|
||||
(CAPS, punctuation, EN+DE keyword phrases, Cyrillic homoglyphs),
|
||||
sender heuristics (suspicious TLDs, digit-heavy domains, Reply-To
|
||||
mismatch, display-name impersonation), bulk-mail signal.
|
||||
Three levels: clean / suspicious / spam.
|
||||
- [x] **Visual marking** in message list: red row + badge for spam,
|
||||
yellow row + badge for suspicious. Hover tooltip shows reasons.
|
||||
- [ ] **Body-content scoring** — `SpamDetector::score()` already accepts
|
||||
a body parameter; wire it up in the message-open route to refine
|
||||
the score with link density / short URL detection. Currently the
|
||||
list view scores headers only.
|
||||
- [x] **User feedback loop** — implemented: `email_spam_rules` table holds
|
||||
block/allow rules per sender email or domain. UI has "select spam"
|
||||
bulk-select button + "spam" / "not spam" buttons in the selection bar.
|
||||
Free email providers (gmail, gmx, etc.) get email-only rules to avoid
|
||||
blocking entire providers; other domains get domain-level rules.
|
||||
Block adds +15, allow subtracts -15 in `SpamDetector`.
|
||||
- [ ] **Bayesian classifier** — collect tokens from user-marked spam vs
|
||||
ham; train a per-account model. SQLite-backed, no external deps.
|
||||
- [ ] **Spam rules management UI** — currently rules are write-only via
|
||||
mark buttons. A list view in settings would let users see and remove
|
||||
stale rules.
|
||||
|
||||
## Hardening
|
||||
|
||||
- [ ] **Rate limiting** on the test endpoint to prevent credential brute force.
|
||||
- [ ] **Better error messages** — currently raw IMAP server errors leak
|
||||
through. Should map common ones (auth failed, connection refused,
|
||||
cert error) to friendly text.
|
||||
- [ ] **Cert pinning option** for users on self-hosted mail servers.
|
||||
- [ ] **Connection reuse** — every API call opens + closes IMAP. A short
|
||||
session cache (per account, 30s) would be much faster.
|
||||
|
||||
## Why these were deferred
|
||||
|
||||
The scope of "build an email plugin" is essentially "build a mail client",
|
||||
which is a multi-month project on its own. The MVP proves the plugin system
|
||||
can host a non-trivial feature with a backend, schema, encrypted
|
||||
credentials, custom API routes, and a complex UI — all without touching
|
||||
the hyprcrd core. Each item above can be added as an isolated patch to this
|
||||
plugin directory, and the plugin can still be disabled in settings to fully
|
||||
remove the feature.
|
||||
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Email Plugin: backend.
|
||||
*
|
||||
* Creates the email_accounts table on first load, encrypts passwords with
|
||||
* the existing vault key, and registers /api/email/* routes via the plugin
|
||||
* route hook.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/email/accounts → list accounts (no passwords)
|
||||
* POST /api/email/accounts → create account
|
||||
* PATCH /api/email/accounts/{id} → update (omit password to keep)
|
||||
* DELETE /api/email/accounts/{id} → remove
|
||||
* POST /api/email/accounts/{id}/test → test IMAP credentials
|
||||
* GET /api/email/messages?account={id}&limit=N → list recent message headers
|
||||
* GET /api/email/message/{accId}/{uid} → fetch + parse one message
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/ImapClient.php';
|
||||
require_once __DIR__ . '/SpamDetector.php';
|
||||
|
||||
// ── Schema migration ──────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 993,
|
||||
encryption TEXT NOT NULL DEFAULT 'ssl',
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS email_spam_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_type TEXT NOT NULL CHECK(rule_type IN ('block','allow')),
|
||||
sender_email TEXT,
|
||||
sender_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_email ON email_spam_rules(sender_email)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_domain ON email_spam_rules(sender_domain)");
|
||||
})();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function emailEncKey(): string {
|
||||
$db = Database::connect();
|
||||
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
|
||||
function emailEncrypt(string $plain): string {
|
||||
$key = emailEncKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
function emailDecrypt(string $encoded): string {
|
||||
$key = emailEncKey();
|
||||
$raw = base64_decode($encoded);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
||||
}
|
||||
|
||||
function emailJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function emailGetAccount(int $id): ?array {
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT * FROM email_accounts WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
function emailLoadSpamRules(): array {
|
||||
$db = Database::connect();
|
||||
return $db->query('SELECT rule_type, sender_email, sender_domain FROM email_spam_rules')->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Free email providers — for these we store the specific email address
|
||||
* (not the domain) when blocklisting, otherwise we'd block all gmail users.
|
||||
*/
|
||||
const EMAIL_FREE_PROVIDERS = [
|
||||
'gmail.com', 'googlemail.com',
|
||||
'outlook.com', 'hotmail.com', 'live.com', 'live.de', 'msn.com',
|
||||
'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'ymail.com',
|
||||
'icloud.com', 'me.com', 'mac.com',
|
||||
'gmx.de', 'gmx.net', 'gmx.com', 'gmx.at', 'gmx.ch',
|
||||
'web.de', 't-online.de', 'freenet.de', 'arcor.de',
|
||||
'aol.com', 'aol.de',
|
||||
'protonmail.com', 'proton.me', 'pm.me',
|
||||
'mail.com', 'mail.ru', 'yandex.com', 'yandex.ru',
|
||||
'tutanota.com', 'tuta.io', 'fastmail.com',
|
||||
];
|
||||
|
||||
function emailExtractSenderParts(string $fromHeader): array {
|
||||
if (preg_match('/<([^>]+)>/', $fromHeader, $m)) {
|
||||
$email = strtolower(trim($m[1]));
|
||||
} elseif (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $fromHeader, $m)) {
|
||||
$email = strtolower(trim($m[0]));
|
||||
} else {
|
||||
return ['email' => '', 'domain' => ''];
|
||||
}
|
||||
$at = strrpos($email, '@');
|
||||
$domain = $at === false ? '' : substr($email, $at + 1);
|
||||
return ['email' => $email, 'domain' => $domain];
|
||||
}
|
||||
|
||||
/** Build a connected, logged-in IMAP client for the given account. */
|
||||
function emailOpenAccount(array $row): ImapClient {
|
||||
$client = new ImapClient(
|
||||
$row['host'],
|
||||
(int)$row['port'],
|
||||
$row['encryption'],
|
||||
15
|
||||
);
|
||||
$client->connect();
|
||||
$client->login(emailDecrypt($row['username']), emailDecrypt($row['password']));
|
||||
$client->selectFolder($row['folder'] ?: 'INBOX');
|
||||
return $client;
|
||||
}
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
// ── Accounts CRUD ──
|
||||
|
||||
if ($route === '/api/email/accounts' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
$rows = $db->query('SELECT id, name, host, port, encryption, folder, position, created_at FROM email_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||
// Return decrypted username (NOT password) so the form can show it
|
||||
foreach ($rows as &$r) {
|
||||
$stmt = $db->prepare('SELECT username FROM email_accounts WHERE id = :id');
|
||||
$stmt->execute(['id' => $r['id']]);
|
||||
$u = $stmt->fetchColumn();
|
||||
$r['username'] = $u ? emailDecrypt($u) : '';
|
||||
}
|
||||
emailJson($rows);
|
||||
}
|
||||
|
||||
if ($route === '/api/email/accounts' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$host = trim((string)($input['host'] ?? ''));
|
||||
$username = (string)($input['username'] ?? '');
|
||||
$password = (string)($input['password'] ?? '');
|
||||
$port = (int)($input['port'] ?? 993);
|
||||
$encryption = in_array($input['encryption'] ?? 'ssl', ['ssl', 'tls', 'none'], true) ? $input['encryption'] : 'ssl';
|
||||
$folder = trim((string)($input['folder'] ?? 'INBOX')) ?: 'INBOX';
|
||||
if ($name === '' || $host === '' || $username === '' || $password === '') {
|
||||
emailJson(['error' => 'name, host, username, and password are required'], 400);
|
||||
}
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO email_accounts (name, host, port, encryption, username, password, folder)
|
||||
VALUES (:n, :h, :p, :e, :u, :pw, :f)
|
||||
');
|
||||
$stmt->execute([
|
||||
'n' => $name, 'h' => $host, 'p' => $port, 'e' => $encryption,
|
||||
'u' => emailEncrypt($username), 'pw' => emailEncrypt($password), 'f' => $folder,
|
||||
]);
|
||||
emailJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||
$id = (int)$m[1];
|
||||
$row = emailGetAccount($id);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$fields = []; $params = ['id' => $id];
|
||||
foreach (['name', 'host', 'folder'] as $k) {
|
||||
if (array_key_exists($k, $input)) { $fields[] = "$k = :$k"; $params[$k] = trim((string)$input[$k]); }
|
||||
}
|
||||
if (array_key_exists('port', $input)) { $fields[] = 'port = :port'; $params['port'] = (int)$input['port']; }
|
||||
if (array_key_exists('encryption', $input) && in_array($input['encryption'], ['ssl','tls','none'], true)) {
|
||||
$fields[] = 'encryption = :encryption'; $params['encryption'] = $input['encryption'];
|
||||
}
|
||||
if (!empty($input['username'])) { $fields[] = 'username = :username'; $params['username'] = emailEncrypt($input['username']); }
|
||||
if (!empty($input['password'])) { $fields[] = 'password = :password'; $params['password'] = emailEncrypt($input['password']); }
|
||||
if (!$fields) emailJson(['ok' => true]);
|
||||
$db = Database::connect();
|
||||
$db->prepare('UPDATE email_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||
emailJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||
$id = (int)$m[1];
|
||||
$db = Database::connect();
|
||||
$db->prepare('DELETE FROM email_accounts WHERE id = :id')->execute(['id' => $id]);
|
||||
emailJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/email/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||
$id = (int)$m[1];
|
||||
$row = emailGetAccount($id);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = emailOpenAccount($row);
|
||||
$info = $client->selectFolder($row['folder'] ?: 'INBOX');
|
||||
$client->logout();
|
||||
emailJson(['ok' => true, 'exists' => $info['exists']]);
|
||||
} catch (\Throwable $e) {
|
||||
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
if ($route === '/api/email/messages' && $method === 'GET') {
|
||||
$accId = (int)($_GET['account'] ?? 0);
|
||||
$limit = max(1, min(200, (int)($_GET['limit'] ?? 30)));
|
||||
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||
$row = emailGetAccount($accId);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = emailOpenAccount($row);
|
||||
$uids = $client->uidSearch('ALL');
|
||||
rsort($uids); // newest first
|
||||
$total = count($uids);
|
||||
$pageUids = array_slice($uids, $offset, $limit);
|
||||
$messages = $client->fetchHeaders($pageUids);
|
||||
usort($messages, fn($a, $b) => $b['uid'] - $a['uid']);
|
||||
$client->logout();
|
||||
|
||||
// Score each message for spam (header signals only at list time —
|
||||
// body checks happen on demand when the message is opened).
|
||||
// Pass user-defined block/allow rules so manual feedback flows in.
|
||||
$detector = new SpamDetector(emailLoadSpamRules());
|
||||
foreach ($messages as &$msg) {
|
||||
$verdict = $detector->score($msg['headers'] ?? []);
|
||||
$msg['spam'] = $verdict;
|
||||
// Don't ship the full headers map to the client (too noisy)
|
||||
unset($msg['headers']);
|
||||
}
|
||||
unset($msg);
|
||||
|
||||
emailJson([
|
||||
'ok' => true,
|
||||
'messages' => $messages,
|
||||
'total' => $total,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if ($route === '/api/email/spam/mark' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$accId = (int)($input['account'] ?? 0);
|
||||
$uids = $input['uids'] ?? [];
|
||||
$action = $input['action'] ?? '';
|
||||
if (!in_array($action, ['spam', 'ham'], true)) {
|
||||
emailJson(['error' => 'action must be spam or ham'], 400);
|
||||
}
|
||||
if (!is_array($uids) || !$uids) emailJson(['error' => 'uids array required'], 400);
|
||||
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||
$row = emailGetAccount($accId);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
|
||||
try {
|
||||
// Fetch the From header for each UID
|
||||
$client = emailOpenAccount($row);
|
||||
$headers = $client->fetchHeaders($cleanUids);
|
||||
$client->logout();
|
||||
|
||||
$db = Database::connect();
|
||||
$ruleType = $action === 'spam' ? 'block' : 'allow';
|
||||
// Remove any opposing rule for the same sender first so toggling works
|
||||
$oppositeType = $action === 'spam' ? 'allow' : 'block';
|
||||
$delStmt = $db->prepare('DELETE FROM email_spam_rules WHERE rule_type = :t AND (sender_email = :e OR sender_domain = :d)');
|
||||
$insStmt = $db->prepare('INSERT INTO email_spam_rules (rule_type, sender_email, sender_domain) VALUES (:t, :e, :d)');
|
||||
|
||||
$added = 0;
|
||||
$seenSenders = [];
|
||||
foreach ($headers as $h) {
|
||||
$parts = emailExtractSenderParts($h['from'] ?? '');
|
||||
if (!$parts['email']) continue;
|
||||
|
||||
// Dedupe within this request
|
||||
$sigKey = $parts['email'];
|
||||
if (isset($seenSenders[$sigKey])) continue;
|
||||
$seenSenders[$sigKey] = true;
|
||||
|
||||
$isFreeProvider = in_array($parts['domain'], EMAIL_FREE_PROVIDERS, true);
|
||||
// For free providers, only store the email; otherwise store both
|
||||
$email = $parts['email'];
|
||||
$domain = $isFreeProvider ? null : $parts['domain'];
|
||||
|
||||
// Drop any opposite rule
|
||||
$delStmt->execute(['t' => $oppositeType, 'e' => $email, 'd' => $domain]);
|
||||
// Avoid duplicate rule of the SAME type
|
||||
$existsStmt = $db->prepare('SELECT 1 FROM email_spam_rules WHERE rule_type = :t AND ((sender_email IS NOT NULL AND sender_email = :e) OR (sender_domain IS NOT NULL AND sender_domain = :d)) LIMIT 1');
|
||||
$existsStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||
if (!$existsStmt->fetchColumn()) {
|
||||
$insStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
emailJson(['ok' => true, 'added' => $added, 'action' => $action]);
|
||||
} catch (\Throwable $e) {
|
||||
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if ($route === '/api/email/delete' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$accId = (int)($input['account'] ?? 0);
|
||||
$uids = $input['uids'] ?? [];
|
||||
if (!is_array($uids) || !$uids) {
|
||||
emailJson(['error' => 'uids array required'], 400);
|
||||
}
|
||||
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||
$row = emailGetAccount($accId);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = emailOpenAccount($row);
|
||||
$count = $client->deleteUids($cleanUids);
|
||||
$client->logout();
|
||||
emailJson(['ok' => true, 'deleted' => $count]);
|
||||
} catch (\Throwable $e) {
|
||||
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/email/message/(\d+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1];
|
||||
$uid = (int)$m[2];
|
||||
$row = emailGetAccount($accId);
|
||||
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = emailOpenAccount($row);
|
||||
$msg = $client->fetchMessage($uid);
|
||||
$client->logout();
|
||||
// Strip HTML if no plaintext available
|
||||
if (!$msg['text'] && $msg['html']) {
|
||||
$msg['text'] = trim(html_entity_decode(strip_tags(preg_replace('/<br\s*\/?>/i', "\n", $msg['html']))));
|
||||
}
|
||||
emailJson([
|
||||
'ok' => true,
|
||||
'uid' => $uid,
|
||||
'subject' => $msg['headers']['subject'] ?? '',
|
||||
'from' => $msg['headers']['from'] ?? '',
|
||||
'to' => $msg['headers']['to'] ?? '',
|
||||
'date' => $msg['headers']['date'] ?? '',
|
||||
'text' => $msg['text'],
|
||||
'html' => $msg['html'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/* Email Plugin styles */
|
||||
|
||||
#email-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
|
||||
|
||||
.email-modal {
|
||||
/* defined inline for sizing */
|
||||
}
|
||||
|
||||
/* Message list — no text selection inside (drag is for multi-select) */
|
||||
#email-list, #email-list * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Message list rows */
|
||||
.email-msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
transition: background 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.email-msg-row:hover {
|
||||
background: var(--t-raised);
|
||||
}
|
||||
.email-msg-row.active {
|
||||
background: var(--t-raised);
|
||||
border-left-color: var(--t-accent);
|
||||
}
|
||||
.email-msg-row.unseen {
|
||||
border-left-color: var(--t-accent2);
|
||||
}
|
||||
.email-msg-row.selected {
|
||||
background: color-mix(in srgb, var(--t-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Spam levels — suspicious yellow, spam red. Override unseen border. */
|
||||
.email-msg-row.spam-suspicious {
|
||||
border-left-color: #fbbf24 !important; /* amber */
|
||||
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||
}
|
||||
.email-msg-row.spam-suspicious:hover {
|
||||
background: color-mix(in srgb, #fbbf24 14%, var(--t-raised));
|
||||
}
|
||||
.email-msg-row.spam-spam {
|
||||
border-left-color: var(--t-danger) !important;
|
||||
background: color-mix(in srgb, var(--t-danger) 12%, transparent);
|
||||
}
|
||||
.email-msg-row.spam-spam:hover {
|
||||
background: color-mix(in srgb, var(--t-danger) 20%, var(--t-raised));
|
||||
}
|
||||
.email-msg-row.spam-spam .email-msg-subject {
|
||||
color: var(--t-danger);
|
||||
}
|
||||
.email-spam-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
.email-spam-badge.spam {
|
||||
background: var(--t-danger);
|
||||
color: var(--t-bg);
|
||||
}
|
||||
.email-spam-badge.suspicious {
|
||||
background: #fbbf24;
|
||||
color: #000;
|
||||
}
|
||||
.email-msg-check {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--t-accent);
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.email-msg-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.email-msg-row.unseen .email-msg-subject {
|
||||
color: var(--t-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
.email-msg-from {
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
font-family: var(--t-font-pixel);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-msg-subject {
|
||||
font-size: 12px;
|
||||
color: var(--t-text);
|
||||
margin-top: 2px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-msg-date {
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
font-family: var(--t-font-pixel);
|
||||
}
|
||||
|
||||
/* Account list (in management view) */
|
||||
.email-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.email-account-row .email-account-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.email-account-row .email-account-name {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 11px;
|
||||
color: var(--t-bright);
|
||||
}
|
||||
.email-account-row .email-account-meta {
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.email-account-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Loading / empty states */
|
||||
.email-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--t-dim);
|
||||
font-size: 10px;
|
||||
font-family: var(--t-font-pixel);
|
||||
}
|
||||
|
||||
#email-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||
|
||||
/* Override Chrome/Safari autofill styling on the account form inputs */
|
||||
#email-acc-username:-webkit-autofill,
|
||||
#email-acc-username:-webkit-autofill:hover,
|
||||
#email-acc-username:-webkit-autofill:focus,
|
||||
#email-acc-password:-webkit-autofill,
|
||||
#email-acc-password:-webkit-autofill:hover,
|
||||
#email-acc-password:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--t-bright);
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||
box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||
caret-color: var(--t-bright);
|
||||
transition: background-color 9999s ease-out, color 9999s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<!-- Email Plugin: full-screen mail viewer overlay -->
|
||||
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
||||
<div class="email-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
||||
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||
<button id="email-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||
<button id="email-close" class="flex items-center gap-1.5 px-2 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>
|
||||
|
||||
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||
<div id="email-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||
<div class="email-welcome text-center max-w-md">
|
||||
<i class="ph ph-envelope text-t-accent text-6xl block mb-4"></i>
|
||||
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to mail</h3>
|
||||
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||
Read mail from any IMAP account inside hyprcrd. Messages stay on the
|
||||
server — only the ones you explicitly extract become cards.
|
||||
To get started, add at least one account in settings.
|
||||
</p>
|
||||
<button id="email-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||
<i class="ph ph-gear text-sm"></i>
|
||||
<span class="font-pixel text-[11px]">Configure accounts</span>
|
||||
</button>
|
||||
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → mail</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Mail viewer (default view when accounts exist) ── -->
|
||||
<div id="email-view-mail" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Message list -->
|
||||
<div id="email-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:38%;">
|
||||
<div class="sticky top-0 z-20 bg-t-bg">
|
||||
<!-- List toolbar: pagination + spam quick-select -->
|
||||
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
||||
<button id="email-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Select all messages flagged as spam">
|
||||
<i class="ph ph-warning-octagon text-sm"></i>
|
||||
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Selection action bar (visible when at least one selected) -->
|
||||
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
||||
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Mark sender as spam (future mail from same sender will be flagged)">
|
||||
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
||||
</button>
|
||||
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)">
|
||||
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
||||
</button>
|
||||
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
||||
</button>
|
||||
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection">
|
||||
<i class="ph ph-x text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="email-list" class="divide-y divide-t-border"></div>
|
||||
</div>
|
||||
<!-- Resizer -->
|
||||
<div id="email-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||
</div>
|
||||
<!-- Message body -->
|
||||
<div id="email-body-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||
<div id="email-body-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||
<div class="text-center">
|
||||
<i class="ph ph-envelope-open text-3xl mb-2 block"></i>
|
||||
<span class="cursor-blink">select a message</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="email-body" class="hidden flex flex-col flex-1 min-h-0">
|
||||
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||
<h3 id="email-msg-subject" class="text-t-bright text-base font-bold mb-2"></h3>
|
||||
<div class="flex items-center gap-3 text-[10px] font-pixel text-t-dim">
|
||||
<span><span class="text-t-muted">from:</span> <span id="email-msg-from" class="text-t-text"></span></span>
|
||||
<span><span class="text-t-muted">date:</span> <span id="email-msg-date" class="text-t-text"></span></span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
||||
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
|
||||
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
|
||||
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
|
||||
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="email-msg-text" class="flex-1 min-h-0 overflow-y-auto text-xs text-t-text whitespace-pre-wrap break-words font-mono leading-relaxed px-5 py-4 m-0"></pre>
|
||||
<iframe id="email-msg-html" class="hidden flex-1 min-h-0 w-full bg-white" sandbox="allow-popups allow-popups-to-escape-sandbox" style="border:none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "email",
|
||||
"version": "1.0.0",
|
||||
"description": "Read IMAP mail accounts in a split-pane viewer. Extract messages as cards. Multiple accounts supported, passwords encrypted with the vault key.",
|
||||
"frontend": {
|
||||
"js": ["email.js"],
|
||||
"css": ["email.css"],
|
||||
"html": ["email.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Minimal Gitea v1 REST client for the hyprcrd gitea plugin.
|
||||
*
|
||||
* Mirrors the Python `gitea_client.py` shape but only implements the
|
||||
* subset needed for MVP: connection test, search/list/get issues, list
|
||||
* comments. Write operations (create issue, comment, edit) are deferred
|
||||
* to a later iteration — see TODO.md.
|
||||
*
|
||||
* Authentication is via the standard Gitea token header:
|
||||
* Authorization: token <TOKEN>
|
||||
*
|
||||
* Errors throw GiteaApiException with status code + body.
|
||||
*/
|
||||
|
||||
class GiteaApiException extends RuntimeException
|
||||
{
|
||||
public int $statusCode;
|
||||
public string $body;
|
||||
|
||||
public function __construct(int $statusCode, string $body)
|
||||
{
|
||||
$this->statusCode = $statusCode;
|
||||
$this->body = $body;
|
||||
parent::__construct("Gitea API HTTP $statusCode: " . substr($body, 0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
class GiteaClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $token;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(string $baseUrl, string $token, int $timeout = 20)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
$this->token = $token;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the token works against the configured base URL by hitting
|
||||
* /user. Returns the authenticated user dict on success.
|
||||
* Throws GiteaApiException on failure.
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
$resp = $this->request('GET', '/user');
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-repo issue search. Mirrors Python search_issues().
|
||||
* @param array $opts Any of: state, labels, milestones, q, type,
|
||||
* since, before, assigned, created, mentioned,
|
||||
* review_requested, reviewed, owner, team,
|
||||
* page, limit
|
||||
*/
|
||||
public function searchIssues(array $opts = []): array
|
||||
{
|
||||
$resp = $this->request('GET', '/repos/issues/search', $opts);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
public function listRepoIssues(string $owner, string $repo, array $opts = []): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
|
||||
$resp = $this->request('GET', $path, $opts);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
public function getIssue(string $owner, string $repo, int $index): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
|
||||
$resp = $this->request('GET', $path);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
public function listIssueComments(string $owner, string $repo, int $index, array $opts = []): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
|
||||
$resp = $this->request('GET', $path, $opts);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/** Post a new comment on an issue. Returns the created comment object. */
|
||||
public function createIssueComment(string $owner, string $repo, int $index, string $body): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
|
||||
$resp = $this->request('POST', $path, [], ['body' => $body]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an issue. Pass any subset of: title, body, state ('open'|'closed'),
|
||||
* assignees (string[]), milestone (int), due_date (RFC3339), unset_due_date (bool).
|
||||
* Returns the updated issue.
|
||||
*/
|
||||
public function editIssue(string $owner, string $repo, int $index, array $changes): array
|
||||
{
|
||||
$allowed = ['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'ref', 'unset_due_date'];
|
||||
$payload = [];
|
||||
foreach ($allowed as $k) {
|
||||
if (array_key_exists($k, $changes)) $payload[$k] = $changes[$k];
|
||||
}
|
||||
if (!$payload) return [];
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
|
||||
$resp = $this->request('PATCH', $path, [], $payload);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the labels of an issue. $labels is an array of label IDs (ints).
|
||||
* Returns the resulting list of labels.
|
||||
*/
|
||||
public function replaceIssueLabels(string $owner, string $repo, int $index, array $labels): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/labels';
|
||||
$resp = $this->request('PUT', $path, [], ['labels' => array_map('intval', $labels)]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/** Fetch the available labels for a repository. */
|
||||
public function listRepoLabels(string $owner, string $repo, int $page = 1, int $limit = 100): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/labels';
|
||||
$resp = $this->request('GET', $path, ['page' => $page, 'limit' => $limit]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new issue in a repository. Pass any of: title (required),
|
||||
* body, assignees (string[]), labels (int[] of label IDs), milestone (int),
|
||||
* due_date (RFC3339), ref, closed (bool).
|
||||
* Returns the created issue.
|
||||
*/
|
||||
public function createIssue(string $owner, string $repo, array $opts): array
|
||||
{
|
||||
if (empty($opts['title'])) return [];
|
||||
$allowed = ['title', 'body', 'assignees', 'labels', 'milestone', 'due_date', 'ref', 'closed'];
|
||||
$payload = [];
|
||||
foreach ($allowed as $k) {
|
||||
if (array_key_exists($k, $opts) && $opts[$k] !== null) $payload[$k] = $opts[$k];
|
||||
}
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
|
||||
$resp = $this->request('POST', $path, [], $payload);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List repositories the authenticated user has access to.
|
||||
* Used to populate the repo picker for "new issue".
|
||||
*/
|
||||
public function listMyRepos(int $page = 1, int $limit = 100): array
|
||||
{
|
||||
$resp = $this->request('GET', '/user/repos', ['page' => $page, 'limit' => $limit]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/** List milestones for a repo (state: 'open' | 'closed' | 'all'). */
|
||||
public function listRepoMilestones(string $owner, string $repo, string $state = 'open', int $limit = 100): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/milestones';
|
||||
$resp = $this->request('GET', $path, ['state' => $state, 'page' => 1, 'limit' => $limit]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List who can be assigned to issues in this repo. This is the correct
|
||||
* endpoint for the "assignee picker": it includes the repo owner, org
|
||||
* members (when the repo is in an org), AND explicit collaborators —
|
||||
* unlike `/collaborators` which only returns explicit ones.
|
||||
*/
|
||||
public function listRepoAssignees(string $owner, string $repo, int $limit = 100): array
|
||||
{
|
||||
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/assignees';
|
||||
$resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List labels defined at the org level. These are inherited by repos in
|
||||
* the org but DON'T appear in `/repos/{owner}/{repo}/labels`.
|
||||
* 404 if the owner is a user (not an org).
|
||||
*/
|
||||
public function listOrgLabels(string $org, int $limit = 100): array
|
||||
{
|
||||
$path = '/orgs/' . rawurlencode($org) . '/labels';
|
||||
$resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]);
|
||||
return is_array($resp) ? $resp : [];
|
||||
}
|
||||
|
||||
// ── Low-level request ─────────────────────────────────────────
|
||||
|
||||
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
||||
{
|
||||
$url = $this->baseUrl . '/api/v1' . $path;
|
||||
$cleanQuery = [];
|
||||
foreach ($query as $k => $v) {
|
||||
if ($v === null || $v === '') continue;
|
||||
if (is_bool($v)) $v = $v ? 'true' : 'false';
|
||||
$cleanQuery[$k] = $v;
|
||||
}
|
||||
if ($cleanQuery) {
|
||||
$url .= '?' . http_build_query($cleanQuery);
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Authorization: token ' . $this->token,
|
||||
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
||||
];
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
];
|
||||
if ($payload !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
|
||||
}
|
||||
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
if ($body === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new RuntimeException("Could not reach Gitea: $err");
|
||||
}
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
|
||||
curl_close($ch);
|
||||
|
||||
if ($status >= 400) {
|
||||
throw new GiteaApiException((int)$status, (string)$body);
|
||||
}
|
||||
|
||||
if ($body === '' || $body === null) return null;
|
||||
if (stripos($contentType, 'application/json') !== false || preg_match('/^\s*[\[\{]/', (string)$body)) {
|
||||
$decoded = json_decode((string)$body, true);
|
||||
return $decoded;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# gitea plugin — roadmap
|
||||
|
||||
> **Status:** scaffold. Phase 1 = accounts CRUD + connection test only.
|
||||
> The `gitea-issue-client/` Python tool in the project root is the
|
||||
> reference for the full feature surface that needs to be ported.
|
||||
|
||||
## Phase 1 — done ✅
|
||||
|
||||
- [x] `GiteaClient.php` cURL wrapper with `testConnection()`,
|
||||
`searchIssues()`, `listRepoIssues()`, `getIssue()`, `listIssueComments()`
|
||||
- [x] `gitea_accounts` table with encrypted token (vault key, same as email)
|
||||
- [x] Routes: `GET/POST/PATCH/DELETE /api/gitea/accounts` + `POST .../test`
|
||||
- [x] Settings tab via `hyprcrd.settings.registerTab`
|
||||
- [x] Header button "gitea"
|
||||
- [x] Welcome view + accounts management form
|
||||
- [x] Connection test against `/api/v1/user`
|
||||
|
||||
## Phase 2 — issue reader ✅
|
||||
|
||||
- [x] **Issue list view** in the overlay:
|
||||
- Search box (`q` parameter, debounced 350ms)
|
||||
- State filter (open/closed/all)
|
||||
- Pagination (prev/next, hasMore heuristic since Gitea doesn't always return total)
|
||||
- Each row: title, repo path, state badge, labels, author, age
|
||||
- [x] **Issue detail view** (right pane):
|
||||
- Subject + state badge + labels + #number + repo
|
||||
- Body (markdown rendered via `hyprcrd.markdown.render` — exposed by core)
|
||||
- Comments list (each with author + relative timestamp + rendered markdown)
|
||||
- "Open on Gitea" link button
|
||||
- [x] **Extract → card**:
|
||||
- Card title = `[repo#number] title`
|
||||
- Card body = metadata header + body + comments + `#gitea #issue` tags
|
||||
- Color: green (#22c55e)
|
||||
- [ ] **Issue → card backlink** in a `gitea_issue_links` table — needed
|
||||
for Phase 5 bidirectional sync
|
||||
|
||||
## Phase 3 — write operations
|
||||
|
||||
- [x] **Add comment** — composer textarea always visible at the bottom of the
|
||||
detail pane. Ctrl/Cmd+Enter posts. New comment is appended to local state
|
||||
and re-rendered immediately.
|
||||
- [x] **Close / reopen** — single button in the action bar that toggles state.
|
||||
Updates the badge, button label, and the row in the list.
|
||||
- [x] **Edit title** — pencil icon next to the title swaps to an inline input.
|
||||
Enter saves, Escape cancels. PATCH `/issues/{index}`.
|
||||
- [x] **Edit body** — pencil icon next to the body swaps to a textarea.
|
||||
Ctrl/Cmd+Enter saves, Escape cancels.
|
||||
- [x] **Set labels** — tag icon opens an inline editor that fetches the repo's
|
||||
label list (`GET /api/gitea/repo/.../labels`) and shows checkboxes with
|
||||
original colors. Save uses `PUT /issues/{index}/labels`.
|
||||
- [ ] **Set assignees** — needs a collaborator typeahead (next round, see
|
||||
TODO Phase 3.5)
|
||||
- [ ] **Set milestone** — needs milestone fetch + dropdown (next round)
|
||||
- [ ] **Set due date** — date picker (next round)
|
||||
|
||||
## Phase 3.5 — remaining metadata
|
||||
|
||||
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` (added with create form)
|
||||
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` (added with create form)
|
||||
- [x] Milestone dropdown — currently in **create** form only
|
||||
- [x] Assignee multi-select — currently in **create** form only
|
||||
- [x] Same metadata editors in the **detail** view: combined inline editor
|
||||
that lets you change assignees, milestone, and due date all at once.
|
||||
Single PATCH call handles all three. Re-fetches the issue after save
|
||||
to keep state in sync.
|
||||
- [x] Due date input + clear button (create skipped due_date but edit has it)
|
||||
- [ ] Due date in the **create** form too (currently only on edit)
|
||||
- [ ] **Project assignment** — gitea's API does NOT include `project` in
|
||||
`CreateIssueOption` or `EditIssueOption`. Issues are added to projects
|
||||
via separate board endpoints which differ across gitea versions. Punt
|
||||
until there's a clear API surface to target.
|
||||
|
||||
## Phase 4 — create issues
|
||||
|
||||
- [x] **"new issue" button** in the list toolbar
|
||||
- [x] **Repo picker** loaded from `/user/repos` (cached per account, filtered
|
||||
to non-archived repos with push permission)
|
||||
- [x] **Title + body + labels** form
|
||||
- [x] **Submit** creates via POST `/repo/{accId}/{owner}/{repo}/issues`,
|
||||
refreshes list, auto-opens the new issue in the detail view
|
||||
- [ ] **Create from card**: a card → push as new gitea issue, store the
|
||||
backref so future edits can sync both ways (Phase 5)
|
||||
- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md`
|
||||
if available (next round)
|
||||
|
||||
## Phase 5 — bidirectional sync (the killer feature)
|
||||
|
||||
- [ ] When a card has a gitea backref, an "issue" badge appears
|
||||
- [ ] Edit the card body in hyprcrd → "push to gitea" button
|
||||
- [ ] "pull from gitea" button refreshes the card from the live issue
|
||||
- [ ] Conflict detection via `updated_at` timestamps
|
||||
- [ ] This replaces the Python tool's `gir push` workflow
|
||||
|
||||
## Phase 6 — multi-instance + ergonomics
|
||||
|
||||
- [ ] Account selector remembers last-used account in localStorage
|
||||
- [ ] Per-account default filters (e.g. always show `assigned=true`)
|
||||
- [ ] Org/repo browsing tree on the left
|
||||
- [ ] Label browser per repo
|
||||
- [ ] Milestone view
|
||||
- [ ] Search across multiple accounts
|
||||
|
||||
## Hardening
|
||||
|
||||
- [ ] Rate limiting on test endpoint
|
||||
- [ ] Better error messages (currently raw HTTP body leaks through)
|
||||
- [ ] Cert pinning option for self-hosted gitea instances
|
||||
- [ ] Connection caching (currently every request opens a new cURL handle)
|
||||
- [ ] Map common errors (401 → "token expired", 403 → "no access", etc.)
|
||||
|
||||
## Mapping from the Python tool
|
||||
|
||||
| Python feature | hyprcrd plan |
|
||||
|----------------------------------------|-------------------------------------------|
|
||||
| `gi search` | overlay issue list with `q` filter |
|
||||
| `gi list --owner --repo` | overlay with owner/repo filter |
|
||||
| `gi get` | overlay detail pane |
|
||||
| `gi create` | Phase 4 "new issue" button |
|
||||
| `gi edit` | Phase 3 detail-pane edit buttons |
|
||||
| `gi comment` | Phase 3 comment box in detail pane |
|
||||
| `gi comments` | Phase 2 comments rendering |
|
||||
| `gir fetch` → markdown files | Phase 2 extract-as-card (no files needed) |
|
||||
| `gir push` → sync local edits | Phase 5 bidirectional sync |
|
||||
| `gir new --push` | Phase 4 create-from-card |
|
||||
| `gir clean` | not needed (cards are managed via the UI) |
|
||||
| `_valid-metadata.md` per repo | label/milestone dropdowns at edit time |
|
||||
| `templates/gir-new-issue.md` | configurable issue template per account |
|
||||
|
||||
Once Phase 2–4 are done, the entire `gitea-issue-client/` Python tool
|
||||
can be removed from the project root.
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Gitea Plugin: backend.
|
||||
*
|
||||
* Replaces the standalone Python `gitea-issue-client` tool. MVP scope:
|
||||
* accounts CRUD + connection test. Issue browsing/extraction comes once
|
||||
* the connection layer is verified. See TODO.md for the deferred items.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/gitea/accounts → list accounts (no tokens)
|
||||
* POST /api/gitea/accounts → create account
|
||||
* PATCH /api/gitea/accounts/{id} → update (omit token to keep)
|
||||
* DELETE /api/gitea/accounts/{id} → remove
|
||||
* POST /api/gitea/accounts/{id}/test → verify token via /api/v1/user
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/GiteaClient.php';
|
||||
|
||||
// ── Schema migration ──────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS gitea_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
})();
|
||||
|
||||
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
||||
|
||||
function giteaEncKey(): string {
|
||||
$db = Database::connect();
|
||||
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||
}
|
||||
|
||||
function giteaEncrypt(string $plain): string {
|
||||
$key = giteaEncKey();
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $cipher);
|
||||
}
|
||||
|
||||
function giteaDecrypt(string $encoded): string {
|
||||
$key = giteaEncKey();
|
||||
$raw = base64_decode($encoded);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
||||
}
|
||||
|
||||
function giteaJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function giteaGetAccount(int $id): ?array {
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
function giteaOpenClient(array $row): GiteaClient {
|
||||
return new GiteaClient($row['base_url'], giteaDecrypt($row['token']));
|
||||
}
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
// ── Accounts CRUD ──
|
||||
|
||||
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||
giteaJson($rows);
|
||||
}
|
||||
|
||||
if ($route === '/api/gitea/accounts' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$baseUrl = trim((string)($input['base_url'] ?? ''));
|
||||
$token = (string)($input['token'] ?? '');
|
||||
if ($name === '' || $baseUrl === '' || $token === '') {
|
||||
giteaJson(['error' => 'name, base_url, and token are required'], 400);
|
||||
}
|
||||
// Normalize base URL: strip trailing slash, accept with/without scheme
|
||||
if (!preg_match('#^https?://#i', $baseUrl)) {
|
||||
$baseUrl = 'https://' . $baseUrl;
|
||||
}
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO gitea_accounts (name, base_url, token)
|
||||
VALUES (:n, :u, :t)
|
||||
');
|
||||
$stmt->execute([
|
||||
'n' => $name,
|
||||
'u' => $baseUrl,
|
||||
't' => giteaEncrypt($token),
|
||||
]);
|
||||
giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||
$id = (int)$m[1];
|
||||
$row = giteaGetAccount($id);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$fields = []; $params = ['id' => $id];
|
||||
if (array_key_exists('name', $input)) {
|
||||
$fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']);
|
||||
}
|
||||
if (array_key_exists('base_url', $input)) {
|
||||
$u = trim((string)$input['base_url']);
|
||||
if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u;
|
||||
$fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/');
|
||||
}
|
||||
if (!empty($input['token'])) {
|
||||
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
||||
}
|
||||
if (!$fields) giteaJson(['ok' => true]);
|
||||
$db = Database::connect();
|
||||
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||
giteaJson(['ok' => true]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||
$id = (int)$m[1];
|
||||
$db = Database::connect();
|
||||
$db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]);
|
||||
giteaJson(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Issues: list ──
|
||||
|
||||
if ($route === '/api/gitea/issues' && $method === 'GET') {
|
||||
$accId = (int)($_GET['account'] ?? 0);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
|
||||
$state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open';
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$owner = trim((string)($_GET['owner'] ?? ''));
|
||||
$scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$limit = max(5, min(100, (int)($_GET['limit'] ?? 30)));
|
||||
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$issues = $client->searchIssues([
|
||||
'state' => $state,
|
||||
'q' => $q !== '' ? $q : null,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'type' => 'issues', // exclude PRs
|
||||
'assigned' => $scope === 'assigned' ? true : null,
|
||||
'created' => $scope === 'created' ? true : null,
|
||||
'mentioned' => $scope === 'mentioned' ? true : null,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
// Map to a compact, UI-friendly shape
|
||||
$mapped = array_map(function ($i) {
|
||||
$repoFull = $i['repository']['full_name'] ?? null;
|
||||
if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) {
|
||||
$repoFull = $i['repository']['owner'] . '/' . $i['repository']['name'];
|
||||
}
|
||||
return [
|
||||
'id' => $i['id'] ?? null,
|
||||
'number' => $i['number'] ?? null,
|
||||
'title' => $i['title'] ?? '',
|
||||
'state' => $i['state'] ?? 'open',
|
||||
'created_at' => $i['created_at'] ?? null,
|
||||
'updated_at' => $i['updated_at'] ?? null,
|
||||
'comments_count' => $i['comments'] ?? 0,
|
||||
'html_url' => $i['html_url'] ?? null,
|
||||
'repo' => $repoFull,
|
||||
'author' => $i['user']['login'] ?? null,
|
||||
'labels' => array_map(fn($l) => [
|
||||
'name' => $l['name'] ?? '',
|
||||
'color' => $l['color'] ?? '888888',
|
||||
], $i['labels'] ?? []),
|
||||
'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []),
|
||||
];
|
||||
}, $issues);
|
||||
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'issues' => $mapped,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue detail (with comments) ──
|
||||
|
||||
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1];
|
||||
$owner = urldecode($m[2]);
|
||||
$repo = urldecode($m[3]);
|
||||
$index = (int)$m[4];
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$issue = $client->getIssue($owner, $repo, $index);
|
||||
$comments = $client->listIssueComments($owner, $repo, $index);
|
||||
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'issue' => [
|
||||
'id' => $issue['id'] ?? null,
|
||||
'number' => $issue['number'] ?? $index,
|
||||
'title' => $issue['title'] ?? '',
|
||||
'body' => $issue['body'] ?? '',
|
||||
'state' => $issue['state'] ?? 'open',
|
||||
'created_at' => $issue['created_at'] ?? null,
|
||||
'updated_at' => $issue['updated_at'] ?? null,
|
||||
'closed_at' => $issue['closed_at'] ?? null,
|
||||
'due_date' => $issue['due_date'] ?? null,
|
||||
'html_url' => $issue['html_url'] ?? null,
|
||||
'author' => $issue['user']['login'] ?? null,
|
||||
'author_avatar' => $issue['user']['avatar_url'] ?? null,
|
||||
'repo' => "$owner/$repo",
|
||||
'owner' => $owner,
|
||||
'repo_name' => $repo,
|
||||
'labels' => array_map(fn($l) => [
|
||||
'name' => $l['name'] ?? '',
|
||||
'color' => $l['color'] ?? '888888',
|
||||
], $issue['labels'] ?? []),
|
||||
'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []),
|
||||
'milestone' => isset($issue['milestone']) && $issue['milestone'] ? [
|
||||
'id' => $issue['milestone']['id'] ?? null,
|
||||
'title' => $issue['milestone']['title'] ?? '',
|
||||
] : null,
|
||||
],
|
||||
'comments' => array_map(fn($c) => [
|
||||
'id' => $c['id'] ?? null,
|
||||
'author' => $c['user']['login'] ?? null,
|
||||
'body' => $c['body'] ?? '',
|
||||
'created_at' => $c['created_at'] ?? null,
|
||||
'updated_at' => $c['updated_at'] ?? null,
|
||||
], $comments),
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue write operations ──
|
||||
|
||||
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$body = trim((string)($input['body'] ?? ''));
|
||||
if ($body === '') giteaJson(['error' => 'comment body required'], 400);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$created = $client->createIssueComment($owner, $repo, $index, $body);
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'comment' => [
|
||||
'id' => $created['id'] ?? null,
|
||||
'author' => $created['user']['login'] ?? null,
|
||||
'body' => $created['body'] ?? '',
|
||||
'created_at' => $created['created_at'] ?? null,
|
||||
],
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$changes = [];
|
||||
foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) {
|
||||
if (array_key_exists($k, $input)) $changes[$k] = $input[$k];
|
||||
}
|
||||
if (!$changes) giteaJson(['error' => 'no changes'], 400);
|
||||
if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) {
|
||||
giteaJson(['error' => 'invalid state'], 400);
|
||||
}
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$updated = $client->editIssue($owner, $repo, $index, $changes);
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'issue' => [
|
||||
'title' => $updated['title'] ?? '',
|
||||
'body' => $updated['body'] ?? '',
|
||||
'state' => $updated['state'] ?? 'open',
|
||||
'updated_at' => $updated['updated_at'] ?? null,
|
||||
'closed_at' => $updated['closed_at'] ?? null,
|
||||
],
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$labels = $input['labels'] ?? [];
|
||||
if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels);
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'labels' => array_map(fn($l) => [
|
||||
'id' => $l['id'] ?? null,
|
||||
'name' => $l['name'] ?? '',
|
||||
'color' => $l['color'] ?? '888888',
|
||||
], $resulting),
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachment proxy ──
|
||||
// Gitea attachments live under /attachments/<uuid> on the gitea instance
|
||||
// and require the auth token (or session cookie). The browser can't fetch
|
||||
// them directly (CORS + no token), so hyprcrd proxies them.
|
||||
|
||||
if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1];
|
||||
$path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png"
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) { http_response_code(404); exit; }
|
||||
|
||||
// Build target URL on the gitea instance
|
||||
$url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/');
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: token ' . giteaDecrypt($row['token']),
|
||||
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
||||
],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
if ($body === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
http_response_code(502);
|
||||
header('Content-Type: text/plain');
|
||||
echo "Could not reach gitea: $err";
|
||||
exit;
|
||||
}
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream';
|
||||
curl_close($ch);
|
||||
|
||||
if ($code >= 400) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain');
|
||||
echo "Gitea returned HTTP $code";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Strip any default headers PHP might emit so we can replace them
|
||||
header_remove('Pragma');
|
||||
header_remove('Expires');
|
||||
header('Content-Type: ' . $contentType);
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('Content-Length: ' . strlen($body));
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Create issue ──
|
||||
|
||||
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$title = trim((string)($input['title'] ?? ''));
|
||||
if ($title === '') giteaJson(['error' => 'title is required'], 400);
|
||||
$opts = ['title' => $title];
|
||||
if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body'];
|
||||
if (array_key_exists('labels', $input) && is_array($input['labels'])) {
|
||||
$opts['labels'] = array_map('intval', $input['labels']);
|
||||
}
|
||||
if (array_key_exists('assignees', $input) && is_array($input['assignees'])) {
|
||||
$opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees'])));
|
||||
}
|
||||
if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone'];
|
||||
if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date'];
|
||||
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$created = $client->createIssue($owner, $repo, $opts);
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'issue' => [
|
||||
'id' => $created['id'] ?? null,
|
||||
'number' => $created['number'] ?? null,
|
||||
'title' => $created['title'] ?? '',
|
||||
'state' => $created['state'] ?? 'open',
|
||||
'html_url' => $created['html_url'] ?? null,
|
||||
'owner' => $owner,
|
||||
'repo_name' => $repo,
|
||||
],
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── User repos (for the repo picker) ──
|
||||
|
||||
if ($route === '/api/gitea/repos' && $method === 'GET') {
|
||||
$accId = (int)($_GET['account'] ?? 0);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$repos = $client->listMyRepos(1, 100);
|
||||
$mapped = array_map(fn($r) => [
|
||||
'id' => $r['id'] ?? null,
|
||||
'full_name' => $r['full_name'] ?? null,
|
||||
'name' => $r['name'] ?? null,
|
||||
'owner' => $r['owner']['login'] ?? null,
|
||||
'description' => $r['description'] ?? '',
|
||||
'private' => $r['private'] ?? false,
|
||||
'archived' => $r['archived'] ?? false,
|
||||
'permissions' => $r['permissions'] ?? null,
|
||||
], $repos);
|
||||
// Filter out archived repos and ones without push permission
|
||||
$mapped = array_values(array_filter($mapped, function ($r) {
|
||||
if ($r['archived']) return false;
|
||||
$p = $r['permissions'] ?? null;
|
||||
if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false;
|
||||
return true; // unknown permissions = include
|
||||
}));
|
||||
giteaJson(['ok' => true, 'repos' => $mapped]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repo metadata ──
|
||||
|
||||
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
// Merge repo-level + org-level labels (org labels are inherited by
|
||||
// repos in an org but don't appear in /repos/.../labels)
|
||||
$repoLabels = $client->listRepoLabels($owner, $repo);
|
||||
$orgLabels = [];
|
||||
$warning = null;
|
||||
try {
|
||||
$orgLabels = $client->listOrgLabels($owner);
|
||||
} catch (GiteaApiException $e) {
|
||||
if ($e->statusCode === 404) {
|
||||
// Owner is a user, not an org — no org labels exist. silent.
|
||||
} elseif ($e->statusCode === 403) {
|
||||
// Token lacks read:organization scope — surface a warning
|
||||
// but don't fail the whole request; repo labels still work.
|
||||
$warning = 'Token lacks read:organization scope — org-level labels are not visible. Update your gitea token to include read:organization to see them.';
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
// Dedupe by id (in case the same label appears in both)
|
||||
$seen = [];
|
||||
$merged = [];
|
||||
foreach (array_merge($repoLabels, $orgLabels) as $l) {
|
||||
$id = $l['id'] ?? null;
|
||||
if ($id !== null && !isset($seen[$id])) {
|
||||
$seen[$id] = true;
|
||||
$merged[] = $l;
|
||||
}
|
||||
}
|
||||
$payload = [
|
||||
'ok' => true,
|
||||
'labels' => array_map(fn($l) => [
|
||||
'id' => $l['id'] ?? null,
|
||||
'name' => $l['name'] ?? '',
|
||||
'color' => $l['color'] ?? '888888',
|
||||
'description' => $l['description'] ?? '',
|
||||
], $merged),
|
||||
];
|
||||
if ($warning) $payload['warning'] = $warning;
|
||||
giteaJson($payload);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/milestones$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$milestones = $client->listRepoMilestones($owner, $repo, 'open');
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'milestones' => array_map(fn($ms) => [
|
||||
'id' => $ms['id'] ?? null,
|
||||
'title' => $ms['title'] ?? '',
|
||||
'description' => $ms['description'] ?? '',
|
||||
'due_on' => $ms['due_on'] ?? null,
|
||||
'state' => $ms['state'] ?? 'open',
|
||||
], $milestones),
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/collaborators$#', $route, $m) && $method === 'GET') {
|
||||
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||
$row = giteaGetAccount($accId);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
// Use /assignees instead of /collaborators — it returns the full
|
||||
// set of users that can be assigned (owner + org members +
|
||||
// explicit collaborators), which is what we actually want.
|
||||
$assignees = $client->listRepoAssignees($owner, $repo);
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'collaborators' => array_map(fn($u) => [
|
||||
'login' => $u['login'] ?? '',
|
||||
'full_name' => $u['full_name'] ?? '',
|
||||
'avatar_url' => $u['avatar_url'] ?? '',
|
||||
], $assignees),
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||
$id = (int)$m[1];
|
||||
$row = giteaGetAccount($id);
|
||||
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||
try {
|
||||
$client = giteaOpenClient($row);
|
||||
$user = $client->testConnection();
|
||||
giteaJson([
|
||||
'ok' => true,
|
||||
'user' => [
|
||||
'login' => $user['login'] ?? null,
|
||||
'full_name' => $user['full_name'] ?? null,
|
||||
'email' => $user['email'] ?? null,
|
||||
'avatar_url' => $user['avatar_url'] ?? null,
|
||||
],
|
||||
'base_url' => $row['base_url'],
|
||||
]);
|
||||
} catch (GiteaApiException $e) {
|
||||
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||
} catch (\Throwable $e) {
|
||||
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/* Gitea Plugin styles */
|
||||
|
||||
#gitea-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
|
||||
|
||||
/* Account list (in settings tab) */
|
||||
.gitea-account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.gitea-account-row .gitea-account-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.gitea-account-row .gitea-account-name {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 11px;
|
||||
color: var(--t-bright);
|
||||
}
|
||||
.gitea-account-row .gitea-account-meta {
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gitea-account-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Issue list rows */
|
||||
.gitea-issue-row {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.gitea-issue-row:hover { background: var(--t-raised); }
|
||||
.gitea-issue-row.active {
|
||||
background: var(--t-raised);
|
||||
border-left-color: var(--t-accent);
|
||||
}
|
||||
.gitea-issue-row .gitea-issue-repo {
|
||||
font-size: 9px;
|
||||
font-family: var(--t-font-pixel);
|
||||
color: var(--t-dim);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gitea-issue-row .gitea-issue-title {
|
||||
font-size: 12px;
|
||||
color: var(--t-text);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.gitea-issue-row.state-closed .gitea-issue-title { color: var(--t-muted); }
|
||||
.gitea-issue-row .gitea-issue-meta {
|
||||
font-size: 9px;
|
||||
font-family: var(--t-font-pixel);
|
||||
color: var(--t-dim);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.gitea-issue-row .gitea-issue-labels {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* State badge (open/closed) */
|
||||
.gitea-state-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.gitea-state-badge.open { background: #22c55e; color: #000; }
|
||||
.gitea-state-badge.closed { background: #a855f7; color: #fff; }
|
||||
|
||||
/* Label chip (mirrors gitea label colors) */
|
||||
.gitea-label {
|
||||
display: inline-block;
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.3px;
|
||||
border: 1px solid rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Comment block */
|
||||
.gitea-comment {
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-surface);
|
||||
border-radius: 2px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gitea-comment-header {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 9px;
|
||||
color: var(--t-dim);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.gitea-comment-author { color: var(--t-bright); }
|
||||
.gitea-comment-body {
|
||||
font-size: 12px;
|
||||
color: var(--t-text);
|
||||
}
|
||||
|
||||
/* Issue list — no text selection so dragging doesn't highlight rows */
|
||||
#gitea-issue-list, #gitea-issue-list * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
#gitea-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||
|
||||
/* Label editor checkboxes */
|
||||
.gitea-label-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.gitea-label-checkbox:hover { background: var(--t-raised); }
|
||||
.gitea-label-checkbox input { accent-color: var(--t-accent); }
|
||||
.gitea-label-checkbox .gitea-label { cursor: pointer; }
|
||||
|
||||
/* Title input matches the h3 sizing so swap is seamless */
|
||||
#gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; }
|
||||
|
||||
/* Custom repo autocomplete dropdown */
|
||||
.gitea-repo-option {
|
||||
padding: 6px 10px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--t-text);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--t-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.gitea-repo-option:last-child { border-bottom: none; }
|
||||
.gitea-repo-option:hover,
|
||||
.gitea-repo-option.active {
|
||||
background: var(--t-raised);
|
||||
color: var(--t-bright);
|
||||
}
|
||||
.gitea-repo-option .gitea-repo-tag {
|
||||
font-family: var(--t-font-pixel);
|
||||
font-size: 8px;
|
||||
color: var(--t-muted);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Override Chrome/Safari autofill on the gitea token field */
|
||||
#gitea-acc-token:-webkit-autofill,
|
||||
#gitea-acc-token:-webkit-autofill:hover,
|
||||
#gitea-acc-token:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--t-bright);
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||
box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||
caret-color: var(--t-bright);
|
||||
transition: background-color 9999s ease-out, color 9999s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
||||
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
||||
<div class="gitea-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-xs text-t-accent">gitea</h2>
|
||||
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||
<button id="gitea-close" class="flex items-center gap-1.5 px-2 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>
|
||||
|
||||
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||
<div id="gitea-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||
<div class="text-center max-w-md">
|
||||
<i class="ph ph-git-pull-request text-t-accent text-6xl block mb-4"></i>
|
||||
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to gitea</h3>
|
||||
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||
Read issues from one or more Gitea instances inside hyprcrd. Issues stay
|
||||
on the server — only the ones you explicitly extract become cards.
|
||||
To get started, add at least one account in settings.
|
||||
</p>
|
||||
<button id="gitea-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||
<i class="ph ph-gear text-sm"></i>
|
||||
<span class="font-pixel text-[11px]">Configure accounts</span>
|
||||
</button>
|
||||
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → gitea</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Issues reader ── -->
|
||||
<div id="gitea-view-issues" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Issue list -->
|
||||
<div id="gitea-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:42%;">
|
||||
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
||||
<!-- Filters bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Issue state">
|
||||
<option value="open">open</option>
|
||||
<option value="closed">closed</option>
|
||||
<option value="all">all</option>
|
||||
</select>
|
||||
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Filter by your involvement">
|
||||
<option value="all">everyone</option>
|
||||
<option value="assigned">assigned to me</option>
|
||||
<option value="created">created by me</option>
|
||||
<option value="mentioned">mentioning me</option>
|
||||
</select>
|
||||
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
||||
<button id="gitea-new-issue" class="toolbar-btn" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
||||
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||
</div>
|
||||
<!-- Pagination toolbar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
||||
<button id="gitea-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gitea-issue-list" class="divide-y divide-t-border"></div>
|
||||
</div>
|
||||
<!-- Resizer -->
|
||||
<div id="gitea-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||
</div>
|
||||
<!-- Issue detail -->
|
||||
<div id="gitea-detail-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||
<div id="gitea-detail-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||
<div class="text-center">
|
||||
<i class="ph ph-list-bullets text-3xl mb-2 block"></i>
|
||||
<span class="cursor-blink">select an issue</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Create issue form ── -->
|
||||
<div id="gitea-create-form" class="hidden flex-col flex-1 min-h-0">
|
||||
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
||||
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
||||
<div class="flex-1"></div>
|
||||
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block relative">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">repository <span id="gitea-create-repo-hint" class="normal-case text-t-muted text-[8px]"></span></span>
|
||||
<input id="gitea-create-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
<div id="gitea-create-repo-dropdown" class="hidden absolute left-0 right-0 top-full z-30 mt-0.5 max-h-56 overflow-y-auto bg-t-surface border border-t-accent shadow-lg"></div>
|
||||
</label>
|
||||
<div id="gitea-create-repo-error" class="hidden px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">title</span>
|
||||
<input id="gitea-create-title" type="text" placeholder="short summary" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-sm focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">body (markdown)</span>
|
||||
<textarea id="gitea-create-body" rows="14" placeholder="describe the issue..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||
</label>
|
||||
<div id="gitea-create-labels-warning" class="hidden mt-3 px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">labels (optional)</span>
|
||||
<div id="gitea-create-labels" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">assignees (optional)</span>
|
||||
<div id="gitea-create-assignees" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">milestone (optional)</span>
|
||||
<select id="gitea-create-milestone" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
<option value="">— no milestone —</option>
|
||||
</select>
|
||||
<p class="text-[9px] text-t-dim mt-1 italic">project: not supported by gitea api on issue create — set it manually on gitea after creation</p>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button id="gitea-create-submit" class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">Create issue</span></button>
|
||||
<button id="gitea-create-cancel-2" class="flex items-center gap-1.5 px-3 py-1.5 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]">Cancel</span></button>
|
||||
</div>
|
||||
<div id="gitea-create-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gitea-detail" class="hidden flex flex-col flex-1 min-h-0">
|
||||
<!-- Detail header -->
|
||||
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<span id="gitea-detail-state" class="gitea-state-badge mt-1">open</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div id="gitea-title-display" class="flex items-center gap-2">
|
||||
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
||||
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
</div>
|
||||
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
||||
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
||||
<button id="gitea-title-save" class="toolbar-btn" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
||||
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
||||
</div>
|
||||
<div class="text-[10px] font-pixel text-t-dim mb-2">
|
||||
<span id="gitea-detail-repo"></span>
|
||||
·
|
||||
<span class="text-t-muted">opened by</span> <span id="gitea-detail-author" class="text-t-text"></span>
|
||||
<span id="gitea-detail-date-wrap"> · <span id="gitea-detail-date" class="text-t-text"></span></span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
||||
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||
</div>
|
||||
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
||||
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||
<div id="gitea-labels-checkboxes" class="space-y-1 max-h-48 overflow-y-auto"></div>
|
||||
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-t-border">
|
||||
<button id="gitea-labels-save" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||
<button id="gitea-labels-cancel" class="flex items-center gap-1.5 px-2 py-0.5 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]">Cancel</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
||||
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
||||
</div>
|
||||
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-users text-xs"></i> assignees</span>
|
||||
<div id="gitea-meta-assignees" class="border border-t-border bg-t-bg p-2 max-h-40 overflow-y-auto">
|
||||
<div class="text-[10px] text-t-dim italic">loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-flag text-xs"></i> milestone</span>
|
||||
<select id="gitea-meta-milestone" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
<option value="">— no milestone —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
<button id="gitea-meta-due-clear" class="toolbar-btn" title="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3 pt-2 border-t border-t-border">
|
||||
<button id="gitea-meta-save" class="flex items-center gap-1.5 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||
<button id="gitea-meta-cancel" class="flex items-center gap-1.5 px-2 py-0.5 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]">Cancel</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<button id="gitea-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
||||
<button id="gitea-state-toggle" class="flex items-center gap-1.5 px-3 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-check-circle text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||
<a id="gitea-open-link" href="#" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-3 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-arrow-square-out text-sm"></i><span class="font-pixel text-[10px]">Open on gitea</span></a>
|
||||
</div>
|
||||
<div id="gitea-extract-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
</div>
|
||||
<!-- Body + comments scrollable -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||
<div id="gitea-body-display" class="flex items-start gap-2">
|
||||
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
||||
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
</div>
|
||||
<div id="gitea-body-edit" class="hidden">
|
||||
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<button id="gitea-body-save" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||
<button id="gitea-body-cancel" class="flex items-center gap-1.5 px-3 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]">Cancel</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gitea-detail-comments-section" class="mt-6 hidden">
|
||||
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim border-b border-t-border pb-1 mb-3"><i class="ph ph-chat-circle text-xs"></i> comments (<span id="gitea-detail-comments-count">0</span>)</div>
|
||||
<div id="gitea-detail-comments" class="space-y-3"></div>
|
||||
</div>
|
||||
<!-- Comment composer (always visible when an issue is open) -->
|
||||
<div class="mt-6 border-t border-t-border pt-4">
|
||||
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-chat-text text-xs"></i> add comment</div>
|
||||
<textarea id="gitea-comment-input" rows="3" placeholder="markdown supported..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<button id="gitea-comment-post" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">Post</span></button>
|
||||
<span id="gitea-comment-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "gitea",
|
||||
"version": "0.1.0",
|
||||
"description": "Gitea issue browser. Read issues and comments from one or more Gitea instances, extract them as cards. Multiple accounts supported, tokens encrypted with the vault key.",
|
||||
"frontend": {
|
||||
"js": ["gitea.js"],
|
||||
"css": ["gitea.css"],
|
||||
"html": ["gitea.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
// Logseq Import Plugin — backend route handler.
|
||||
//
|
||||
// Registers POST /api/import-logseq which accepts a Logseq vault ZIP upload and:
|
||||
// 1. Copies vault assets into public/uploads/ with randomized filenames
|
||||
// 2. Rewrites markdown image references to point at the new upload paths
|
||||
// 3. Strips Logseq-specific bullet outline prefixes and property blocks
|
||||
// 4. Creates a card per journal entry (date-titled) and per page
|
||||
// 5. Places everything in an "import" bucket (created if missing)
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method): void {
|
||||
if ($route !== '/api/import-logseq' || $method !== 'POST') return;
|
||||
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'no file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'invalid zip']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = Database::connect();
|
||||
$uploadDir = dirname(__DIR__, 2) . '/uploads';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||
|
||||
// Find the vault root (the directory containing journals/ or pages/)
|
||||
$prefix = '';
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
|
||||
if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
|
||||
}
|
||||
|
||||
// 1. Copy assets → uploads/ with randomized filenames and build a rename map.
|
||||
$assetMap = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (!str_starts_with($name, $prefix . 'assets/')) continue;
|
||||
if (str_ends_with($name, '/')) continue;
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content === false) continue;
|
||||
$origName = basename($name);
|
||||
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
|
||||
$newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
|
||||
file_put_contents($uploadDir . '/' . $newName, $content);
|
||||
$assetMap[$origName] = $newName;
|
||||
}
|
||||
|
||||
// Helper: rewrite image references in markdown body to the new upload paths.
|
||||
$rewriteImages = function ($body) use ($assetMap) {
|
||||
// Logseq patterns: , , 
|
||||
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function ($m) use ($assetMap) {
|
||||
$file = basename($m[2]);
|
||||
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
|
||||
return $m[0];
|
||||
}, $body);
|
||||
// Also handle URL-encoded filenames in the assets path.
|
||||
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function ($m) use ($assetMap) {
|
||||
$file = urldecode(basename($m[2]));
|
||||
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
|
||||
return $m[0];
|
||||
}, $body);
|
||||
return $body;
|
||||
};
|
||||
|
||||
// Helper: strip Logseq-specific syntax (property blocks + bullet outline prefixes).
|
||||
$cleanBody = function ($body) {
|
||||
$lines = explode("\n", $body);
|
||||
$cleaned = [];
|
||||
foreach ($lines as $line) {
|
||||
// Skip Logseq properties (key:: value at start of line)
|
||||
if (preg_match('/^[a-z_-]+::\s/', $line)) continue;
|
||||
// Remove leading "- " bullet outline format (Logseq default)
|
||||
$line = preg_replace('/^(\t*)- /', '$1', $line);
|
||||
$cleaned[] = $line;
|
||||
}
|
||||
return implode("\n", $cleaned);
|
||||
};
|
||||
|
||||
// Ensure the "import" bucket exists.
|
||||
$importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn();
|
||||
if (!$importBucket) {
|
||||
$db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute();
|
||||
$importBucket = $db->lastInsertId();
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
|
||||
// 2. Import journals → date-titled cards.
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (!str_starts_with($name, $prefix . 'journals/')) continue;
|
||||
if (!str_ends_with($name, '.md')) continue;
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content === false || !trim($content)) continue;
|
||||
|
||||
$basename = pathinfo(basename($name), PATHINFO_FILENAME);
|
||||
// 2025_06_18 → 2025-06-18
|
||||
$title = str_replace('_', '-', $basename);
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue;
|
||||
|
||||
$body = $rewriteImages($cleanBody($content));
|
||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
|
||||
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
|
||||
|
||||
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)')
|
||||
->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
// 3. Import pages → cards with the page name as title.
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (!str_starts_with($name, $prefix . 'pages/')) continue;
|
||||
if (!str_ends_with($name, '.md')) continue;
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content === false || !trim($content)) continue;
|
||||
|
||||
$title = pathinfo(basename($name), PATHINFO_FILENAME);
|
||||
// Skip Logseq internal pages.
|
||||
if (in_array(strtolower($title), ['contents', 'favorites'])) continue;
|
||||
|
||||
$body = $rewriteImages($cleanBody($content));
|
||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
|
||||
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
|
||||
|
||||
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)')
|
||||
->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]);
|
||||
exit;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Logseq Import Plugin — frontend.
|
||||
//
|
||||
// Injects a footer button + hidden file input, posts the selected ZIP to
|
||||
// /api/import-logseq, and refreshes the board when the import completes.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) throw new Error('[logseq-import] window.hyprcrd not found');
|
||||
|
||||
function injectFooterButton() {
|
||||
const footer = document.querySelector('footer');
|
||||
if (!footer) return;
|
||||
if (footer.querySelector('#logseq-import-btn')) return;
|
||||
|
||||
// Anchor before the settings button so the order matches the previous core layout.
|
||||
const settingsBtn = footer.querySelector('#settings-btn');
|
||||
const group = settingsBtn?.parentElement || footer.querySelector('.flex.items-center.gap-2');
|
||||
if (!group) return;
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.id = 'logseq-import-file';
|
||||
fileInput.accept = '.zip,application/zip';
|
||||
fileInput.className = 'hidden';
|
||||
group.insertBefore(fileInput, settingsBtn || null);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'logseq-import-btn';
|
||||
btn.className = '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';
|
||||
btn.innerHTML = '<i class="ph ph-graph text-sm"></i><span class="font-pixel text-[10px]">Logseq import</span>';
|
||||
group.insertBefore(btn, settingsBtn || null);
|
||||
|
||||
btn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('api/import-logseq', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-CSRF-Token': hyprcrd.util.csrf() },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
await window._reloadAll?.();
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'logseq import',
|
||||
message: `${data.imported} cards, ${data.assets} images imported`,
|
||||
okLabel: 'Ok',
|
||||
});
|
||||
} catch (err) {
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'logseq import failed',
|
||||
message: String(err?.message || err) || 'unknown error',
|
||||
danger: true,
|
||||
okLabel: 'Ok',
|
||||
});
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectFooterButton);
|
||||
} else {
|
||||
injectFooterButton();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin({
|
||||
name: 'logseq-import',
|
||||
version: '1.0.0',
|
||||
description: 'Import a Logseq vault ZIP as dated journal cards and pages.',
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "logseq-import",
|
||||
"version": "1.0.0",
|
||||
"description": "Import a Logseq vault ZIP — journals become dated cards, pages become cards with the page name as title, and assets are copied into uploads/ with rewritten references.",
|
||||
"frontend": {
|
||||
"js": ["logseq-import.js"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "slideshow",
|
||||
"version": "1.0.0",
|
||||
"description": "Presentation plugin — render a card group as a slideshow. Image slides with invisible clickable hotspot rects that navigate between slides.",
|
||||
"frontend": {
|
||||
"js": ["slideshow.js"],
|
||||
"css": ["slideshow.css"],
|
||||
"html": ["slideshow.html"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/* ── Slideshow Plugin ── */
|
||||
|
||||
/* ── Fenced block preview (in normal card views) ── */
|
||||
/* `inline-block + max-width:100%` doesn't reliably constrain the container when the
|
||||
img's intrinsic size is larger than the parent, so we use block + fit-content instead.
|
||||
That way the container shrinks to the img while still respecting the parent width cap. */
|
||||
.slide-image-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
line-height: 0;
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.slide-image-preview img,
|
||||
.prose .slide-image-preview img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot {
|
||||
position: absolute;
|
||||
border: 1px dashed var(--t-accent);
|
||||
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot:hover {
|
||||
background: color-mix(in srgb, var(--t-accent) 25%, transparent);
|
||||
border-color: var(--t-bright);
|
||||
}
|
||||
/* Hover tooltip: shows the resolved target label (usually the card title) */
|
||||
.slide-image-preview .slide-hotspot[data-label]::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 6px;
|
||||
background: var(--t-surface);
|
||||
border: 1px solid var(--t-accent);
|
||||
color: var(--t-bright);
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
.slide-image-preview .slide-hotspot[data-label]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Viewer ── */
|
||||
#slideshow-viewer.hidden { display: none; }
|
||||
|
||||
#slideshow-slide .slide-text {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 6vh 8vw;
|
||||
overflow: auto;
|
||||
color: var(--t-text);
|
||||
background: var(--t-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2vh;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-title {
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: clamp(18px, 3.2vw, 42px);
|
||||
color: var(--t-accent);
|
||||
margin: 0;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose {
|
||||
font-size: clamp(14px, 1.8vw, 22px);
|
||||
line-height: 1.55;
|
||||
max-width: 80ch;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose h1,
|
||||
#slideshow-slide .slide-text .slide-prose h2,
|
||||
#slideshow-slide .slide-text .slide-prose h3 { color: var(--t-bright); margin-top: 0.8em; }
|
||||
#slideshow-slide .slide-text .slide-prose p { margin: 0.5em 0; }
|
||||
#slideshow-slide .slide-text .slide-prose img { max-width: 100%; height: auto; }
|
||||
#slideshow-slide .slide-text .slide-prose pre {
|
||||
background: var(--t-surface);
|
||||
padding: 0.8em 1em;
|
||||
border: 1px solid var(--t-border);
|
||||
overflow: auto;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
#slideshow-slide .slide-text .slide-prose code { font-family: var(--t-font-mono, monospace); }
|
||||
|
||||
#slideshow-slide .slide-image-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#slideshow-slide .slide-image-wrap > img {
|
||||
display: block;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
#slideshow-slide .slide-hotspot-live {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
/* Nearly-invisible fill: transparent divs sometimes don't register hits reliably, this forces them to. */
|
||||
background: rgba(0, 0, 0, 0.001);
|
||||
border: 0;
|
||||
pointer-events: auto;
|
||||
z-index: 5;
|
||||
transition: background 0.15s, border 0.15s;
|
||||
}
|
||||
#slideshow-viewer.debug-hotspots .slide-hotspot-live {
|
||||
background: color-mix(in srgb, var(--t-accent) 18%, transparent);
|
||||
border: 1px dashed var(--t-accent);
|
||||
}
|
||||
#slideshow-slide .slide-hotspot-live:hover {
|
||||
background: color-mix(in srgb, var(--t-accent) 15%, transparent);
|
||||
border: 1px solid var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Group-bar button ── */
|
||||
#group-bar .slideshow-present-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--t-muted);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-button);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
#group-bar .slideshow-present-btn:hover {
|
||||
color: var(--t-accent);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Detail-sidebar button (icon-only, sits next to the "group" label) ── */
|
||||
#detail-sidebar .slideshow-present-btn-sidebar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 4px;
|
||||
color: var(--t-muted);
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: var(--ui-radius-button);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
#detail-sidebar .slideshow-present-btn-sidebar:hover {
|
||||
color: var(--t-accent);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
|
||||
/* ── Hotspot editor ── */
|
||||
#slideshow-editor.hidden { display: none; }
|
||||
#slideshow-editor[disabled], #slideshow-editor [disabled] { opacity: 0.4; pointer-events: none; }
|
||||
#slideshow-ed-stage { cursor: crosshair; }
|
||||
#slideshow-ed-layer { pointer-events: auto; }
|
||||
#slideshow-ed-layer .ed-rect {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--t-accent);
|
||||
background: color-mix(in srgb, var(--t-accent) 10%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
#slideshow-ed-layer .ed-rect.selected {
|
||||
border: 2px solid var(--t-bright);
|
||||
background: color-mix(in srgb, var(--t-bright) 18%, transparent);
|
||||
cursor: move;
|
||||
}
|
||||
#slideshow-ed-layer .ed-rect .ed-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
margin-bottom: 2px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 9px;
|
||||
padding: 1px 3px;
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
#slideshow-ed-layer .ed-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--t-bright);
|
||||
border: 1px solid var(--t-bg);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
#slideshow-ed-layer .ed-handle[data-handle="nw"] { left: -5px; top: -5px; cursor: nwse-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="n"] { left: 50%; top: -5px; cursor: ns-resize; transform: translateX(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="ne"] { right: -5px; top: -5px; cursor: nesw-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="e"] { right: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="se"] { right: -5px; bottom: -5px; cursor: nwse-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="s"] { left: 50%; bottom: -5px; cursor: ns-resize; transform: translateX(-50%); }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="sw"] { left: -5px; bottom: -5px; cursor: nesw-resize; }
|
||||
#slideshow-ed-layer .ed-handle[data-handle="w"] { left: -5px; top: 50%; cursor: ew-resize; transform: translateY(-50%); }
|
||||
#slideshow-ed-layer .ed-rect-draft {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--t-bright);
|
||||
background: color-mix(in srgb, var(--t-bright) 8%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Gallery picker ── */
|
||||
#slideshow-gallery-picker.hidden { display: none; }
|
||||
.slideshow-gp-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: var(--t-bg);
|
||||
border: 1px solid var(--t-border);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border 0.15s;
|
||||
}
|
||||
.slideshow-gp-item:hover { border-color: var(--t-accent); }
|
||||
.slideshow-gp-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.slideshow-gp-item .slideshow-gp-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 3px 5px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: var(--t-text);
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Target picker ── */
|
||||
#slideshow-picker.hidden { display: none; }
|
||||
.slideshow-pick-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
font-family: var(--t-font-pixel, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--t-text);
|
||||
border: 1px solid var(--t-border);
|
||||
background: var(--t-bg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.slideshow-pick-row:hover {
|
||||
border-color: var(--t-accent);
|
||||
color: var(--t-accent);
|
||||
}
|
||||
#slideshow-pick-list .slideshow-pick-row {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
#slideshow-pick-list .slideshow-pick-row:hover {
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- Slideshow Plugin: Viewer + Hotspot-Editor -->
|
||||
|
||||
<!-- Fullscreen presentation viewer -->
|
||||
<div id="slideshow-viewer" class="hidden fixed inset-0" style="z-index:120;background:#000;">
|
||||
<div id="slideshow-slide" class="absolute inset-0 flex items-center justify-center overflow-hidden"></div>
|
||||
|
||||
<!-- HUD -->
|
||||
<div id="slideshow-hud" class="absolute flex items-center justify-between px-4 py-2 text-t-muted" style="left:0;right:0;bottom:0;background:linear-gradient(to top,rgba(0,0,0,0.6),transparent);pointer-events:none;">
|
||||
<div class="font-pixel text-[10px]" id="slideshow-title"></div>
|
||||
<div class="flex items-center gap-3 font-pixel text-[10px]" style="pointer-events:auto;">
|
||||
<button id="slideshow-prev" class="hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-base"></i></button>
|
||||
<span id="slideshow-counter">1 / 1</span>
|
||||
<button id="slideshow-next" class="hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-base"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="slideshow-close" class="hover:text-t-danger transition-colors"><i class="ph ph-x text-base"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotspot editor -->
|
||||
<div id="slideshow-editor" class="hidden fixed inset-0" style="z-index:90;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:92vw;max-width:1100px;height:88vh;max-height:820px;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0 flex-wrap">
|
||||
<span class="font-pixel text-[10px] text-t-accent">slide hotspots</span>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-2 text-t-muted text-xs">
|
||||
<span class="font-pixel text-[10px]">image:</span>
|
||||
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
||||
</label>
|
||||
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
||||
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
||||
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
||||
</div>
|
||||
<!-- Stage -->
|
||||
<div class="flex-1 min-h-0 flex items-center justify-center bg-t-bg p-4" style="overflow:auto;">
|
||||
<div id="slideshow-ed-stage" style="position:relative;display:inline-block;user-select:none;max-width:100%;max-height:100%;">
|
||||
<img id="slideshow-ed-img" alt="" style="display:block;max-width:100%;max-height:70vh;pointer-events:none;">
|
||||
<div id="slideshow-ed-layer" class="absolute inset-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="slideshow-ed-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
<button id="slideshow-ed-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 cursor-pointer" style="border-radius:3px;">Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery picker for hotspot editor -->
|
||||
<div id="slideshow-gallery-picker" class="hidden fixed inset-0" style="z-index:95;background:rgba(0,0,0,0.55);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:80vw;max-width:900px;height:80vh;max-height:720px;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<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>
|
||||
<span class="font-pixel text-xs text-t-accent">pick image</span>
|
||||
<span id="slideshow-gp-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button id="slideshow-gp-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>
|
||||
</button>
|
||||
<input type="file" id="slideshow-gp-file" accept="image/*" multiple class="hidden">
|
||||
<button id="slideshow-gp-cancel" 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]">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="slideshow-gp-grid" class="flex-1 min-h-0 p-4 grid gap-3 content-start select-none" style="overflow:auto;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target-Picker (second overlay on top of editor) -->
|
||||
<div id="slideshow-picker" class="hidden fixed inset-0" style="z-index:95;background:rgba(0,0,0,0.5);">
|
||||
<div class="bg-t-surface border border-t-border shadow-xl flex flex-col" style="width:420px;max-height:70vh;margin:auto;position:absolute;inset:0;border-radius:6px;">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-t-border shrink-0">
|
||||
<i class="ph ph-arrow-elbow-down-right text-sm text-t-accent"></i>
|
||||
<span class="font-pixel text-[10px] text-t-accent">hotspot target</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 p-3 border-b border-t-border shrink-0">
|
||||
<button data-pick-target="slide:next" class="slideshow-pick-row"><i class="ph ph-caret-right text-sm"></i><span>Next slide</span></button>
|
||||
<button data-pick-target="slide:prev" class="slideshow-pick-row"><i class="ph ph-caret-left text-sm"></i><span>Previous slide</span></button>
|
||||
<button data-pick-target="slide:first" class="slideshow-pick-row"><i class="ph ph-skip-back text-sm"></i><span>First slide</span></button>
|
||||
</div>
|
||||
<div class="px-3 py-2 shrink-0 border-b border-t-border">
|
||||
<input id="slideshow-pick-search" type="text" placeholder="search cards…" class="w-full bg-t-bg border border-t-border text-t-text text-xs" style="padding:4px 8px;border-radius:3px;">
|
||||
</div>
|
||||
<div id="slideshow-pick-list" class="flex-1 min-h-0 p-2 flex flex-col gap-1" style="overflow:auto;"></div>
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="slideshow-pick-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,792 @@
|
||||
// ── Slideshow Plugin ──
|
||||
//
|
||||
// Stellt eine Karten-Gruppe (Parent + Children) als Präsentation dar.
|
||||
// - Text-Folien: normaler Markdown-Content wird fullscreen gerendert.
|
||||
// - Bild-Folien: Card-Body enthält einen ```slide-image block mit Bild + Hotspot-Rects.
|
||||
// Rects sind unsichtbar, aber klickbar und springen zu anderen Folien.
|
||||
//
|
||||
// Datenformat (fenced block ```slide-image```):
|
||||
//
|
||||
// src: /uploads/foo.png
|
||||
// 10,20,30,15 -> card:42
|
||||
// 50,60,20,10 -> slide:next
|
||||
//
|
||||
// Koordinaten in Prozent. Targets:
|
||||
// card:<id> → springt zu Karte mit <id> (wenn in der Gruppe)
|
||||
// slide:next → nächste Folie
|
||||
// slide:prev → vorherige Folie
|
||||
// slide:first → Startfolie
|
||||
// slide:<n> → Folie mit Index n (0-basiert)
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) throw new Error('[slideshow] window.hyprcrd not found');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const escHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
// ── Fenced block parser/serializer ───────────────────────────────
|
||||
|
||||
function parseSlideBlock(content) {
|
||||
const lines = String(content || '').split('\n');
|
||||
let src = '';
|
||||
const hotspots = [];
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (/^src\s*:/i.test(line)) {
|
||||
src = line.replace(/^src\s*:\s*/i, '').trim();
|
||||
continue;
|
||||
}
|
||||
const m = line.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*(?:->|→)\s*(.+)$/);
|
||||
if (m) hotspots.push({ x: +m[1], y: +m[2], w: +m[3], h: +m[4], target: m[5].trim() });
|
||||
}
|
||||
return { src, hotspots };
|
||||
}
|
||||
|
||||
function serializeSlideBlock({ src, hotspots }) {
|
||||
const r = (n) => Math.round(Number(n) * 100) / 100;
|
||||
const out = [`src: ${src || ''}`];
|
||||
for (const h of hotspots) {
|
||||
out.push(`${r(h.x)},${r(h.y)},${r(h.w)},${r(h.h)} -> ${h.target}`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Find an existing ```slide-image``` block that contains the cursor position
|
||||
// (or returns the first one if no cursor given). Returns { start, end, content } or null.
|
||||
function findSlideBlock(value, cursor) {
|
||||
const re = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
||||
let m;
|
||||
let first = null;
|
||||
while ((m = re.exec(value)) !== null) {
|
||||
const start = m.index;
|
||||
const end = m.index + m[0].length;
|
||||
const entry = { start, end, content: m[1] };
|
||||
if (first === null) first = entry;
|
||||
if (cursor != null && cursor >= start && cursor <= end) return entry;
|
||||
}
|
||||
// Fall back to the first block in the body so the user can edit it from anywhere.
|
||||
return first;
|
||||
}
|
||||
|
||||
// Detect if a card body is a "pure image slide" — body's rendered slide block is the primary content.
|
||||
function extractCardSlide(body) {
|
||||
const b = String(body || '');
|
||||
const m = b.match(/```slide-image\s*\n([\s\S]*?)(?:\n```|$)/);
|
||||
if (!m) return null;
|
||||
return parseSlideBlock(m[1]);
|
||||
}
|
||||
|
||||
// ── Card title cache (for resolving hotspot targets to human labels) ───
|
||||
|
||||
const cardTitleCache = new Map(); // id → title
|
||||
|
||||
async function refreshCardTitles() {
|
||||
try {
|
||||
const cards = await hyprcrd.api('api/cards/meta');
|
||||
if (Array.isArray(cards)) {
|
||||
cardTitleCache.clear();
|
||||
for (const c of cards) cardTitleCache.set(String(c.id), c.title || '');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resolveTargetLabel(target) {
|
||||
const t = String(target || '').trim();
|
||||
if (!t) return '';
|
||||
if (t === 'slide:next') return 'next slide';
|
||||
if (t === 'slide:prev') return 'previous slide';
|
||||
if (t === 'slide:first') return 'first slide';
|
||||
const slideMatch = t.match(/^slide:(\d+)$/);
|
||||
if (slideMatch) return `slide ${slideMatch[1]}`;
|
||||
const cardMatch = t.match(/^card:(.+)$/);
|
||||
if (cardMatch) {
|
||||
const id = cardMatch[1];
|
||||
return cardTitleCache.get(String(id)) || '';
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// Fire off initial cache load (non-blocking)
|
||||
refreshCardTitles();
|
||||
|
||||
// ── Markdown fenced block renderer (preview in normal card views) ──
|
||||
|
||||
hyprcrd.markdown.registerFencedBlock('slide-image', (content, { esc }) => {
|
||||
const { src, hotspots } = parseSlideBlock(content);
|
||||
if (!src) return `<div class="slide-image-preview" style="padding:8px;color:var(--t-dim);font-family:var(--t-font-pixel,monospace);font-size:10px;line-height:1.2;">slide-image: missing src</div>`;
|
||||
const rects = hotspots.map(h => {
|
||||
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
||||
const label = resolveTargetLabel(h.target);
|
||||
const labelAttr = label ? ` data-label="${esc(label)}"` : '';
|
||||
return `<div class="slide-hotspot" style="${style}"${labelAttr}></div>`;
|
||||
}).join('');
|
||||
return `<div class="slide-image-preview"><img src="${esc(src)}" alt="">${rects}</div>`;
|
||||
});
|
||||
|
||||
// ── Viewer ─────────────────────────────────────────────────────────
|
||||
|
||||
const viewer = $('slideshow-viewer');
|
||||
const viewerSlide = $('slideshow-slide');
|
||||
const viewerTitle = $('slideshow-title');
|
||||
const viewerCounter = $('slideshow-counter');
|
||||
const viewerPrev = $('slideshow-prev');
|
||||
const viewerNext = $('slideshow-next');
|
||||
const viewerClose = $('slideshow-close');
|
||||
|
||||
let vSlides = [];
|
||||
let vCurrent = 0;
|
||||
let vOpen = false;
|
||||
|
||||
function openViewer(slides, startIdx = 0) {
|
||||
if (!viewer || !slides?.length) return;
|
||||
vSlides = slides;
|
||||
vCurrent = Math.max(0, Math.min(startIdx, slides.length - 1));
|
||||
vOpen = true;
|
||||
viewer.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
renderViewer();
|
||||
document.addEventListener('keydown', onViewerKey, true);
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
if (!vOpen) return;
|
||||
vOpen = false;
|
||||
viewer.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', onViewerKey, true);
|
||||
viewerSlide.innerHTML = '';
|
||||
vSlides = [];
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
const card = vSlides[vCurrent];
|
||||
if (!card) return;
|
||||
viewerTitle.textContent = card.title || '';
|
||||
viewerCounter.textContent = `${vCurrent + 1} / ${vSlides.length}`;
|
||||
|
||||
const slide = extractCardSlide(card.body);
|
||||
if (slide && slide.src) {
|
||||
renderImageSlide(slide);
|
||||
} else {
|
||||
renderTextSlide(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextSlide(card) {
|
||||
const mdRender = hyprcrd.markdown?.render;
|
||||
const html = mdRender ? mdRender(card.body || '') : `<pre>${escHtml(card.body || '')}</pre>`;
|
||||
viewerSlide.innerHTML = `
|
||||
<div class="slide-text">
|
||||
${card.title ? `<h1 class="slide-title">${escHtml(card.title)}</h1>` : ''}
|
||||
<div class="slide-prose prose">${html}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderImageSlide(slide) {
|
||||
const rects = slide.hotspots.map((h, i) => {
|
||||
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
||||
const label = resolveTargetLabel(h.target) || h.target || '';
|
||||
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" title="${escHtml(label)}"></div>`;
|
||||
}).join('');
|
||||
viewerSlide.innerHTML = `
|
||||
<div class="slide-image-wrap">
|
||||
<img src="${escHtml(slide.src)}" alt="">
|
||||
${rects}
|
||||
</div>`;
|
||||
|
||||
viewerSlide.querySelectorAll('[data-hs]').forEach(el => {
|
||||
const idx = parseInt(el.dataset.hs, 10);
|
||||
el.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const target = slide.hotspots[idx]?.target;
|
||||
if (target) await navigateTarget(target);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateTarget(target) {
|
||||
const t = String(target).trim();
|
||||
if (t === 'slide:next') return nextSlide();
|
||||
if (t === 'slide:prev') return prevSlide();
|
||||
if (t === 'slide:first') { vCurrent = 0; renderViewer(); return; }
|
||||
const slideMatch = t.match(/^slide:(\d+)$/);
|
||||
if (slideMatch) {
|
||||
const idx = parseInt(slideMatch[1], 10);
|
||||
if (idx >= 0 && idx < vSlides.length) { vCurrent = idx; renderViewer(); }
|
||||
return;
|
||||
}
|
||||
const cardMatch = t.match(/^card:(.+)$/);
|
||||
if (cardMatch) {
|
||||
const id = cardMatch[1];
|
||||
let idx = vSlides.findIndex(c => String(c.id) === String(id));
|
||||
if (idx < 0) {
|
||||
// Cross-group jump: load the target card from the API and append it to the slide list
|
||||
try {
|
||||
const cards = await hyprcrd.api('api/cards?ids=' + encodeURIComponent(id));
|
||||
const card = Array.isArray(cards) ? cards.find(c => String(c.id) === String(id)) : null;
|
||||
if (card) {
|
||||
vSlides.push(card);
|
||||
idx = vSlides.length - 1;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (idx >= 0) { vCurrent = idx; renderViewer(); }
|
||||
else console.warn('[slideshow] target card not found:', id);
|
||||
return;
|
||||
}
|
||||
console.warn('[slideshow] unknown target:', target);
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (vCurrent < vSlides.length - 1) { vCurrent++; renderViewer(); }
|
||||
}
|
||||
function prevSlide() {
|
||||
if (vCurrent > 0) { vCurrent--; renderViewer(); }
|
||||
}
|
||||
|
||||
function onViewerKey(e) {
|
||||
if (!vOpen) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeViewer(); return; }
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); nextSlide(); return; }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); e.stopPropagation(); prevSlide(); return; }
|
||||
if (e.key === 'Home') { e.preventDefault(); e.stopPropagation(); vCurrent = 0; renderViewer(); return; }
|
||||
if (e.key === 'End') { e.preventDefault(); e.stopPropagation(); vCurrent = vSlides.length - 1; renderViewer(); return; }
|
||||
if (e.key === 'h' || e.key === 'H') { viewer.classList.toggle('debug-hotspots'); e.preventDefault(); }
|
||||
}
|
||||
|
||||
viewerPrev?.addEventListener('click', (e) => { e.stopPropagation(); prevSlide(); });
|
||||
viewerNext?.addEventListener('click', (e) => { e.stopPropagation(); nextSlide(); });
|
||||
viewerClose?.addEventListener('click', (e) => { e.stopPropagation(); closeViewer(); });
|
||||
|
||||
// ── Start viewer from group-bar button ───────────────────────────
|
||||
|
||||
async function startPresentationForGroup(parentId) {
|
||||
if (parentId == null) return;
|
||||
// Fetch full cards so bodies are available
|
||||
const all = await hyprcrd.api('api/cards');
|
||||
if (!Array.isArray(all)) return;
|
||||
// Refresh title cache from the same payload (saves a round-trip)
|
||||
cardTitleCache.clear();
|
||||
for (const c of all) cardTitleCache.set(String(c.id), c.title || '');
|
||||
const parent = all.find(c => String(c.id) === String(parentId));
|
||||
if (!parent) return;
|
||||
const children = all
|
||||
.filter(c => String(c.parent_id) === String(parentId))
|
||||
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
const slides = [parent, ...children];
|
||||
openViewer(slides, 0);
|
||||
}
|
||||
|
||||
// ── Group-bar button injection ───────────────────────────────────
|
||||
|
||||
function injectGroupBarButton() {
|
||||
const bar = $('group-bar');
|
||||
if (!bar) return;
|
||||
if (bar.classList.contains('hidden')) return;
|
||||
if (!bar.dataset.groupId) return;
|
||||
if (bar.querySelector('.slideshow-present-btn')) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'slideshow-present-btn';
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = '<i class="ph ph-projector-screen text-sm"></i><span>present</span>';
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pid = bar.dataset.groupId;
|
||||
if (pid) await startPresentationForGroup(pid);
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
}
|
||||
|
||||
const groupBar = $('group-bar');
|
||||
if (groupBar) {
|
||||
const mo = new MutationObserver(() => injectGroupBarButton());
|
||||
mo.observe(groupBar, { childList: true, subtree: false, attributes: true, attributeFilter: ['class'] });
|
||||
injectGroupBarButton();
|
||||
}
|
||||
|
||||
// ── Detail-view sidebar button (shown when browsing a group card) ──
|
||||
|
||||
function injectDetailSidebarButton() {
|
||||
const sidebar = $('detail-sidebar');
|
||||
if (!sidebar) return;
|
||||
if (sidebar.querySelector('.slideshow-present-btn')) return;
|
||||
// Sidebar header is the first direct child div
|
||||
const header = sidebar.firstElementChild;
|
||||
if (!header) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'slideshow-present-btn slideshow-present-btn-sidebar';
|
||||
btn.type = 'button';
|
||||
btn.title = 'present this group';
|
||||
btn.innerHTML = '<i class="ph ph-projector-screen text-sm"></i>';
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pid = sidebar.dataset.groupId;
|
||||
if (pid) await startPresentationForGroup(pid);
|
||||
});
|
||||
header.appendChild(btn);
|
||||
}
|
||||
|
||||
if ($('detail-sidebar')) injectDetailSidebarButton();
|
||||
else document.addEventListener('DOMContentLoaded', injectDetailSidebarButton, { once: true });
|
||||
|
||||
// ── Hotspot editor ───────────────────────────────────────────────
|
||||
|
||||
const ed = {
|
||||
overlay: $('slideshow-editor'),
|
||||
img: $('slideshow-ed-img'),
|
||||
stage: $('slideshow-ed-stage'),
|
||||
layer: $('slideshow-ed-layer'),
|
||||
srcInput: $('slideshow-ed-src'),
|
||||
galleryBtn: $('slideshow-ed-gallery'),
|
||||
pickBtn: $('slideshow-ed-pick'),
|
||||
fileInput: $('slideshow-ed-file'),
|
||||
deleteBtn: $('slideshow-ed-delete'),
|
||||
okBtn: $('slideshow-ed-ok'),
|
||||
cancelBtn: $('slideshow-ed-cancel'),
|
||||
};
|
||||
|
||||
let edHotspots = [];
|
||||
let edSelected = -1;
|
||||
let edDrag = null; // { startX, startY, draft }
|
||||
let edOnDone = null; // callback({src, hotspots}) or null for cancel
|
||||
|
||||
function edOpen({ src, hotspots }, onDone) {
|
||||
edHotspots = hotspots.map(h => ({ ...h }));
|
||||
edSelected = -1;
|
||||
edOnDone = onDone;
|
||||
ed.srcInput.value = src || '';
|
||||
ed.img.src = src || '';
|
||||
ed.overlay.classList.remove('hidden');
|
||||
renderEdRects();
|
||||
updateEdDeleteBtn();
|
||||
// Refresh card titles so labels resolve correctly
|
||||
refreshCardTitles().then(renderEdRects);
|
||||
}
|
||||
|
||||
function edClose(commit) {
|
||||
ed.overlay.classList.add('hidden');
|
||||
if (commit && edOnDone) {
|
||||
edOnDone({ src: ed.srcInput.value.trim(), hotspots: edHotspots });
|
||||
}
|
||||
edOnDone = null;
|
||||
edHotspots = [];
|
||||
edSelected = -1;
|
||||
edDrag = null;
|
||||
}
|
||||
|
||||
const EDGE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
||||
|
||||
function renderEdRects() {
|
||||
ed.layer.innerHTML = '';
|
||||
edHotspots.forEach((h, i) => {
|
||||
const el = document.createElement('div');
|
||||
const isSelected = i === edSelected;
|
||||
el.className = 'ed-rect' + (isSelected ? ' selected' : '');
|
||||
el.style.left = h.x + '%';
|
||||
el.style.top = h.y + '%';
|
||||
el.style.width = h.w + '%';
|
||||
el.style.height = h.h + '%';
|
||||
el.dataset.idx = String(i);
|
||||
let handles = '';
|
||||
if (isSelected) {
|
||||
handles = EDGE_HANDLES.map(d => `<div class="ed-handle" data-handle="${d}"></div>`).join('');
|
||||
}
|
||||
const labelText = resolveTargetLabel(h.target) || h.target || '?';
|
||||
el.innerHTML = `<span class="ed-label">${escHtml(labelText)}</span>${handles}`;
|
||||
el.addEventListener('mousedown', (e) => onRectMouseDown(e, i));
|
||||
el.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
edSelected = i;
|
||||
openPicker((target) => {
|
||||
if (target) { edHotspots[i].target = target; renderEdRects(); }
|
||||
});
|
||||
});
|
||||
ed.layer.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function updateEdDeleteBtn() {
|
||||
if (!ed.deleteBtn) return;
|
||||
if (edSelected >= 0) ed.deleteBtn.removeAttribute('disabled');
|
||||
else ed.deleteBtn.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
|
||||
function edLayerPct(e) {
|
||||
const imgRect = ed.img.getBoundingClientRect();
|
||||
const x = ((e.clientX - imgRect.left) / imgRect.width) * 100;
|
||||
const y = ((e.clientY - imgRect.top) / imgRect.height) * 100;
|
||||
return {
|
||||
x: Math.max(0, Math.min(100, x)),
|
||||
y: Math.max(0, Math.min(100, y)),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPct(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
||||
|
||||
function onRectMouseDown(e, idx) {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const handleEl = e.target.closest('.ed-handle');
|
||||
edSelected = idx;
|
||||
const p = edLayerPct(e);
|
||||
const startRect = { ...edHotspots[idx] };
|
||||
if (handleEl) {
|
||||
edDrag = { mode: 'resize', startX: p.x, startY: p.y, startRect, idx, handle: handleEl.dataset.handle };
|
||||
} else {
|
||||
edDrag = { mode: 'move', startX: p.x, startY: p.y, startRect, idx };
|
||||
}
|
||||
renderEdRects();
|
||||
updateEdDeleteBtn();
|
||||
}
|
||||
|
||||
function onEdMouseDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest('.ed-rect')) return;
|
||||
if (!ed.img.src || !ed.img.complete) return;
|
||||
e.preventDefault();
|
||||
edSelected = -1;
|
||||
updateEdDeleteBtn();
|
||||
// Re-render existing rects first so the prior selection clears visually —
|
||||
// doing this AFTER creating the draft would wipe it since renderEdRects
|
||||
// replaces ed.layer.innerHTML.
|
||||
renderEdRects();
|
||||
const p = edLayerPct(e);
|
||||
const draft = document.createElement('div');
|
||||
draft.className = 'ed-rect-draft';
|
||||
draft.style.left = p.x + '%';
|
||||
draft.style.top = p.y + '%';
|
||||
draft.style.width = '0%';
|
||||
draft.style.height = '0%';
|
||||
ed.layer.appendChild(draft);
|
||||
edDrag = { mode: 'create', startX: p.x, startY: p.y, draft };
|
||||
}
|
||||
|
||||
function onEdMouseMove(e) {
|
||||
if (!edDrag) return;
|
||||
const p = edLayerPct(e);
|
||||
|
||||
if (edDrag.mode === 'create') {
|
||||
const x = Math.min(edDrag.startX, p.x);
|
||||
const y = Math.min(edDrag.startY, p.y);
|
||||
const w = Math.abs(p.x - edDrag.startX);
|
||||
const h = Math.abs(p.y - edDrag.startY);
|
||||
edDrag.draft.style.left = x + '%';
|
||||
edDrag.draft.style.top = y + '%';
|
||||
edDrag.draft.style.width = w + '%';
|
||||
edDrag.draft.style.height = h + '%';
|
||||
return;
|
||||
}
|
||||
|
||||
// move / resize on existing rect
|
||||
const dx = p.x - edDrag.startX;
|
||||
const dy = p.y - edDrag.startY;
|
||||
const s = edDrag.startRect;
|
||||
let r = { x: s.x, y: s.y, w: s.w, h: s.h };
|
||||
|
||||
if (edDrag.mode === 'move') {
|
||||
r.x = clampPct(s.x + dx, 0, 100 - s.w);
|
||||
r.y = clampPct(s.y + dy, 0, 100 - s.h);
|
||||
} else if (edDrag.mode === 'resize') {
|
||||
const h = edDrag.handle;
|
||||
if (h.includes('w')) { r.x = s.x + dx; r.w = s.w - dx; }
|
||||
if (h.includes('e')) { r.w = s.w + dx; }
|
||||
if (h.includes('n')) { r.y = s.y + dy; r.h = s.h - dy; }
|
||||
if (h.includes('s')) { r.h = s.h + dy; }
|
||||
// Flip when dragged past the opposite edge
|
||||
if (r.w < 0) { r.x = r.x + r.w; r.w = -r.w; }
|
||||
if (r.h < 0) { r.y = r.y + r.h; r.h = -r.h; }
|
||||
// Clamp inside the image
|
||||
if (r.x < 0) { r.w = Math.max(0, r.w + r.x); r.x = 0; }
|
||||
if (r.y < 0) { r.h = Math.max(0, r.h + r.y); r.y = 0; }
|
||||
if (r.x + r.w > 100) r.w = 100 - r.x;
|
||||
if (r.y + r.h > 100) r.h = 100 - r.y;
|
||||
}
|
||||
|
||||
edHotspots[edDrag.idx] = { ...r, target: s.target };
|
||||
const el = ed.layer.querySelector(`.ed-rect[data-idx="${edDrag.idx}"]`);
|
||||
if (el) {
|
||||
el.style.left = r.x + '%';
|
||||
el.style.top = r.y + '%';
|
||||
el.style.width = r.w + '%';
|
||||
el.style.height = r.h + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function onEdMouseUp(e) {
|
||||
if (!edDrag) return;
|
||||
|
||||
if (edDrag.mode === 'create') {
|
||||
const p = edLayerPct(e);
|
||||
const x = Math.min(edDrag.startX, p.x);
|
||||
const y = Math.min(edDrag.startY, p.y);
|
||||
const w = Math.abs(p.x - edDrag.startX);
|
||||
const h = Math.abs(p.y - edDrag.startY);
|
||||
edDrag.draft.remove();
|
||||
edDrag = null;
|
||||
if (w < 1 || h < 1) { renderEdRects(); return; }
|
||||
openPicker((target) => {
|
||||
if (!target) { renderEdRects(); return; }
|
||||
edHotspots.push({ x, y, w, h, target });
|
||||
edSelected = edHotspots.length - 1;
|
||||
renderEdRects();
|
||||
updateEdDeleteBtn();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// move / resize: prune degenerate rect, keep selection
|
||||
const idx = edDrag.idx;
|
||||
edDrag = null;
|
||||
const r = edHotspots[idx];
|
||||
if (r && (r.w < 0.5 || r.h < 0.5)) {
|
||||
edHotspots.splice(idx, 1);
|
||||
edSelected = -1;
|
||||
updateEdDeleteBtn();
|
||||
}
|
||||
renderEdRects();
|
||||
}
|
||||
|
||||
ed.stage?.addEventListener('mousedown', onEdMouseDown);
|
||||
window.addEventListener('mousemove', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseMove(e); });
|
||||
window.addEventListener('mouseup', (e) => { if (ed.overlay && !ed.overlay.classList.contains('hidden')) onEdMouseUp(e); });
|
||||
|
||||
ed.deleteBtn?.addEventListener('click', () => {
|
||||
if (edSelected < 0) return;
|
||||
edHotspots.splice(edSelected, 1);
|
||||
edSelected = -1;
|
||||
renderEdRects();
|
||||
updateEdDeleteBtn();
|
||||
});
|
||||
|
||||
ed.okBtn?.addEventListener('click', () => edClose(true));
|
||||
ed.cancelBtn?.addEventListener('click', () => edClose(false));
|
||||
ed.overlay?.addEventListener('click', (e) => { if (e.target === ed.overlay) edClose(false); });
|
||||
|
||||
ed.srcInput?.addEventListener('change', () => {
|
||||
ed.img.src = ed.srcInput.value.trim();
|
||||
});
|
||||
|
||||
ed.pickBtn?.addEventListener('click', () => ed.fileInput?.click());
|
||||
ed.fileInput?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const url = await uploadImage(file);
|
||||
if (url) {
|
||||
ed.srcInput.value = url;
|
||||
ed.img.src = url;
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
ed.galleryBtn?.addEventListener('click', () => {
|
||||
openGalleryPicker((url) => {
|
||||
if (url) {
|
||||
ed.srcInput.value = url;
|
||||
ed.img.src = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function uploadImage(file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name || 'slide.png');
|
||||
try {
|
||||
const res = await fetch('api/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-CSRF-Token': hyprcrd.util.csrf() },
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.url || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gallery picker (for hotspot editor) ──────────────────────────
|
||||
|
||||
const gp = {
|
||||
overlay: $('slideshow-gallery-picker'),
|
||||
grid: $('slideshow-gp-grid'),
|
||||
count: $('slideshow-gp-count'),
|
||||
uploadBtn: $('slideshow-gp-upload'),
|
||||
fileInput: $('slideshow-gp-file'),
|
||||
cancelBtn: $('slideshow-gp-cancel'),
|
||||
};
|
||||
|
||||
let gpOnDone = null;
|
||||
|
||||
async function openGalleryPicker(onDone) {
|
||||
gpOnDone = onDone;
|
||||
gp.overlay.classList.remove('hidden');
|
||||
await refreshGalleryPicker();
|
||||
}
|
||||
|
||||
function closeGalleryPicker(url) {
|
||||
gp.overlay.classList.add('hidden');
|
||||
const cb = gpOnDone;
|
||||
gpOnDone = null;
|
||||
if (cb) cb(url || null);
|
||||
}
|
||||
|
||||
async function refreshGalleryPicker() {
|
||||
let files = [];
|
||||
try { files = await hyprcrd.api('api/uploads') || []; } catch {}
|
||||
const images = files.filter(f => !/\.(mp3|ogg|wav|m4a)$/i.test(f.url || ''));
|
||||
gp.count.textContent = `${images.length} image${images.length !== 1 ? 's' : ''}`;
|
||||
if (!images.length) {
|
||||
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>
|
||||
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no images yet — upload above</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
gp.grid.innerHTML = images.map(f => `
|
||||
<div class="slideshow-gp-item" data-url="${escHtml(f.url)}">
|
||||
<img src="${escHtml(f.url)}" loading="lazy" alt="">
|
||||
<div class="slideshow-gp-name">${escHtml(f.name || '')}</div>
|
||||
</div>`).join('');
|
||||
gp.grid.querySelectorAll('[data-url]').forEach(el => {
|
||||
el.addEventListener('click', () => closeGalleryPicker(el.dataset.url));
|
||||
});
|
||||
}
|
||||
|
||||
gp.cancelBtn?.addEventListener('click', () => closeGalleryPicker(null));
|
||||
gp.overlay?.addEventListener('click', (e) => { if (e.target === gp.overlay) closeGalleryPicker(null); });
|
||||
gp.uploadBtn?.addEventListener('click', () => gp.fileInput?.click());
|
||||
gp.fileInput?.addEventListener('change', async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
for (const file of files) await uploadImage(file);
|
||||
e.target.value = '';
|
||||
await refreshGalleryPicker();
|
||||
});
|
||||
|
||||
// ── Target picker ────────────────────────────────────────────────
|
||||
|
||||
const picker = {
|
||||
overlay: $('slideshow-picker'),
|
||||
search: $('slideshow-pick-search'),
|
||||
list: $('slideshow-pick-list'),
|
||||
cancelBtn: $('slideshow-pick-cancel'),
|
||||
};
|
||||
|
||||
let pickerOnDone = null;
|
||||
let pickerCards = [];
|
||||
|
||||
async function openPicker(onDone) {
|
||||
pickerOnDone = onDone;
|
||||
picker.overlay.classList.remove('hidden');
|
||||
picker.search.value = '';
|
||||
// Lazy-load cards list
|
||||
if (pickerCards.length === 0) {
|
||||
const cards = await hyprcrd.api('api/cards');
|
||||
if (Array.isArray(cards)) pickerCards = cards;
|
||||
}
|
||||
renderPickerList('');
|
||||
setTimeout(() => picker.search?.focus(), 10);
|
||||
}
|
||||
|
||||
function closePicker(target) {
|
||||
picker.overlay.classList.add('hidden');
|
||||
const cb = pickerOnDone;
|
||||
pickerOnDone = null;
|
||||
if (cb) cb(target || null);
|
||||
}
|
||||
|
||||
function renderPickerList(query) {
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? pickerCards.filter(c => (c.title || '').toLowerCase().includes(q))
|
||||
: pickerCards.slice(0, 50);
|
||||
picker.list.innerHTML = filtered.map(c => {
|
||||
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>`;
|
||||
}).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.list?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-pick-target]');
|
||||
if (btn) closePicker(btn.dataset.pickTarget);
|
||||
});
|
||||
picker.overlay?.addEventListener('click', (e) => {
|
||||
if (e.target === picker.overlay) closePicker(null);
|
||||
});
|
||||
picker.cancelBtn?.addEventListener('click', () => closePicker(null));
|
||||
|
||||
// Relative targets (wired via event delegation on the whole overlay)
|
||||
picker.overlay?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-pick-target]');
|
||||
if (btn && !e.target.closest('#slideshow-pick-list')) closePicker(btn.dataset.pickTarget);
|
||||
});
|
||||
|
||||
picker.search?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closePicker(null); }
|
||||
else if (e.key === 'Enter') {
|
||||
const first = picker.list?.querySelector('[data-pick-target]');
|
||||
if (first) closePicker(first.dataset.pickTarget);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Editor toolbar command + button ──────────────────────────────
|
||||
|
||||
hyprcrd.editor.registerCommand('slideHotspots', () => {
|
||||
const api = hyprcrd.editor.api;
|
||||
if (!api?.body) return;
|
||||
const body = api.body;
|
||||
const val = body.value;
|
||||
const pos = body.selectionStart;
|
||||
|
||||
const existing = findSlideBlock(val, pos);
|
||||
let initial;
|
||||
if (existing) {
|
||||
initial = parseSlideBlock(existing.content);
|
||||
} else {
|
||||
initial = { src: '', hotspots: [] };
|
||||
}
|
||||
|
||||
edOpen(initial, (result) => {
|
||||
if (!result) return;
|
||||
const serialized = serializeSlideBlock(result);
|
||||
const block = '```slide-image\n' + serialized + '\n```';
|
||||
if (existing) {
|
||||
body.setRangeText(block, existing.start, existing.end, 'end');
|
||||
} else {
|
||||
const s = body.selectionStart;
|
||||
const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : '';
|
||||
body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end');
|
||||
}
|
||||
api.updatePreview?.();
|
||||
});
|
||||
});
|
||||
|
||||
function injectToolbarButton() {
|
||||
const toolbar = $('md-toolbar');
|
||||
if (!toolbar) return;
|
||||
if (toolbar.querySelector('[data-md="slideHotspots"]')) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('data-md', 'slideHotspots');
|
||||
btn.setAttribute('title', 'Image slide / hotspots');
|
||||
btn.className = 'toolbar-btn';
|
||||
btn.innerHTML = '<i class="ph ph-image-square text-sm"></i>';
|
||||
const spacer = toolbar.querySelector('.flex-1');
|
||||
if (spacer) toolbar.insertBefore(btn, spacer);
|
||||
else toolbar.appendChild(btn);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectToolbarButton);
|
||||
} else {
|
||||
injectToolbarButton();
|
||||
}
|
||||
|
||||
// ── Plugin self-registration ─────────────────────────────────────
|
||||
|
||||
hyprcrd.registerPlugin({
|
||||
name: 'slideshow',
|
||||
version: '1.0.0',
|
||||
description: 'Presentation mode for card groups with image slides and clickable hotspots.',
|
||||
});
|
||||
@@ -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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
afterEach(async () => {
|
||||
@@ -100,7 +100,11 @@ describe('Bucket Context Menu', () => {
|
||||
it('move-left disabled on first bucket', async () => {
|
||||
await window._reloadAll?.();
|
||||
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;
|
||||
tabs[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 50 }));
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
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 ──
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
describe('Card: Tag Operations', () => {
|
||||
@@ -59,10 +59,15 @@ describe('Card: Due Date & Done', () => {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ due_date: '2099-12-31' }),
|
||||
});
|
||||
state.activeBucket = 'all';
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
// Poll for the card to appear in the DOM instead of a fixed sleep
|
||||
let cardEl = null;
|
||||
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');
|
||||
expect(pill).toExist();
|
||||
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 () => {
|
||||
if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return;
|
||||
if (state.hideDone || state.hideTagged) return;
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
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() },
|
||||
body: JSON.stringify({ due_date: '2020-01-01' }),
|
||||
});
|
||||
state.activeBucket = 'all';
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
let cardEl = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (cardEl) break;
|
||||
await sleep(50);
|
||||
}
|
||||
expect(cardEl).toExist();
|
||||
// Overdue cards get danger border via inline style
|
||||
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
|
||||
});
|
||||
});
|
||||
@@ -110,7 +119,7 @@ describe('Card: Context Menu Actions', () => {
|
||||
rightClick(card);
|
||||
await sleep(150);
|
||||
const favBtn = $$('#ctx-items [data-action="favorite"]')[0];
|
||||
const wasFav = favBtn?.textContent.includes('unfavorite');
|
||||
const wasFav = favBtn?.textContent.toLowerCase().includes('unfavorite');
|
||||
click(favBtn);
|
||||
await sleep(200);
|
||||
|
||||
@@ -118,13 +127,53 @@ describe('Card: Context Menu Actions', () => {
|
||||
rightClick(card);
|
||||
await sleep(150);
|
||||
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);
|
||||
// Toggle back
|
||||
click(favBtn2);
|
||||
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 () => {
|
||||
const res = await fetch('api/cards', {
|
||||
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 }),
|
||||
}).then(r => r.json());
|
||||
|
||||
state.activeBucket = 'all';
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// Child should not be a standalone card on board
|
||||
const childEl = document.querySelector(`[data-id="${child.id}"]`);
|
||||
// It may or may not render depending on group view, but parent should exist
|
||||
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||
let parentEl = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||
if (parentEl) break;
|
||||
await sleep(50);
|
||||
}
|
||||
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', () => {
|
||||
afterEach(async () => {
|
||||
@@ -52,4 +52,170 @@ describe('Context Menu', () => {
|
||||
await sleep(50);
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
afterEach(() => closeAll());
|
||||
@@ -48,8 +48,12 @@ describe('Detail View', () => {
|
||||
if (!card) return;
|
||||
click(card);
|
||||
await sleep(300);
|
||||
if (!$('#detail')?.classList.contains('open')) return;
|
||||
pressKey('Escape');
|
||||
await sleep(150);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (!$('#detail')?.classList.contains('open')) break;
|
||||
await sleep(50);
|
||||
}
|
||||
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', () => {
|
||||
afterEach(() => closeAll());
|
||||
@@ -68,7 +68,13 @@ describe('Editor: Save Card', () => {
|
||||
await sleep(50);
|
||||
|
||||
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();
|
||||
// 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', () => {
|
||||
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
|
||||
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', () => {
|
||||
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', () => {
|
||||
it('export endpoint returns ZIP', async () => {
|
||||
@@ -41,30 +41,26 @@ describe('Export: Markdown Vault', () => {
|
||||
});
|
||||
|
||||
describe('Export: Backup Integrity', () => {
|
||||
it('export→import round-trip preserves card and bucket count', async () => {
|
||||
// Snapshot current state
|
||||
it('export→import round-trip removes extra cards (replace mode)', async () => {
|
||||
// Snapshot + export
|
||||
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());
|
||||
|
||||
// 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', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
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();
|
||||
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 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);
|
||||
});
|
||||
|
||||
@@ -114,4 +110,28 @@ describe('Export: Backup Integrity', () => {
|
||||
expect(found.tags).toContain('beta');
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
afterEach(() => closeAll());
|
||||
@@ -9,7 +9,10 @@ describe('Gallery', () => {
|
||||
|
||||
it('opens on button click', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -19,13 +22,19 @@ describe('Gallery', () => {
|
||||
|
||||
it('shows image count', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('closes on close button', async () => {
|
||||
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');
|
||||
await sleep(150);
|
||||
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', () => {
|
||||
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', () => {
|
||||
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() {
|
||||
const gutter = $('#editor-gutter');
|
||||
|
||||
@@ -2,28 +2,97 @@
|
||||
//
|
||||
// The test runner is heavy (40+ test files), so we only load it when:
|
||||
// - 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
|
||||
// test runner code loaded at all.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
|
||||
// Cache-bust the test modules so edits to test files are picked up without
|
||||
// a hard refresh. The browser treats each unique URL as a distinct module,
|
||||
// so a fresh query string on each page load forces re-import.
|
||||
const V = '?v=' + Date.now();
|
||||
|
||||
// Auto-load if ?test in query string
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
import('./tests.js');
|
||||
ensureProgressBar();
|
||||
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 => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey && (e.key === 'T' || e.key === 't')) {
|
||||
e.preventDefault();
|
||||
import('./tests.js').then(() =>
|
||||
import('./runner.js').then(m => m.open?.())
|
||||
ensureProgressBar();
|
||||
import('./tests.js' + V).then(() =>
|
||||
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?.({
|
||||
name: 'tests',
|
||||
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';
|
||||
|
||||
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 { 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';
|
||||
|
||||
describe('Multi-Select', () => {
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user