Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ad208714 | ||
|
|
d3d9355191 | ||
|
|
ee192a15e7 | ||
|
|
22a43fdef7 | ||
|
|
bbfe968ff1 | ||
|
|
d1b26e625a | ||
|
|
838dfe2a6e | ||
|
|
3078906548 |
No files matched your search
@@ -8,3 +8,6 @@
|
||||
*.swo
|
||||
.DS_Store
|
||||
*.zip
|
||||
electron/node_modules
|
||||
data/hyprcrd.db-shm
|
||||
data/hyprcrd.db-wal
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.20');
|
||||
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,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';
|
||||
@@ -26,7 +26,12 @@ body.no-scanlines::after { display: none !important; }
|
||||
}
|
||||
|
||||
#main-header { box-shadow: var(--ui-shadow-header); }
|
||||
#main-footer { box-shadow: var(--ui-shadow-footer); }
|
||||
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
|
||||
#footer-count { flex-shrink: 0; }
|
||||
@media (min-width: 1250px) {
|
||||
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
|
||||
}
|
||||
html { min-width: 1150px; }
|
||||
|
||||
/* Cards: geometry & shadow driven by design tokens */
|
||||
.board-card {
|
||||
@@ -907,6 +912,7 @@ body.anim-slow .board-card.card-pulse-local {
|
||||
.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 {
|
||||
|
||||
+99
-23
@@ -409,7 +409,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
|
||||
// List all upload files
|
||||
if ($route === '/api/uploads' && $method === 'GET') {
|
||||
$dir = __DIR__ . '/uploads';
|
||||
$dir = UPLOAD_DIR;
|
||||
$files = [];
|
||||
if (is_dir($dir)) {
|
||||
foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
|
||||
@@ -424,7 +424,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
|
||||
// Delete an upload file
|
||||
if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') {
|
||||
$file = __DIR__ . '/uploads/' . basename($m[1]);
|
||||
$file = UPLOAD_DIR . '/' . basename($m[1]);
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
echo json_encode(['ok' => true]);
|
||||
@@ -437,7 +437,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
|
||||
// Upload (max 10MB, real MIME check)
|
||||
if ($route === '/api/upload' && $method === 'POST') {
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
@@ -560,7 +560,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
|
||||
// Copy referenced uploads → attachments/
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
if (is_dir($uploadDir)) {
|
||||
foreach (glob($uploadDir . '/*') as $f) {
|
||||
$name = basename($f);
|
||||
@@ -609,7 +609,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
$zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// Only include referenced uploads
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
$allBodies = implode("\n", array_column($cards, 'body'));
|
||||
if (is_dir($uploadDir)) {
|
||||
foreach (glob($uploadDir . '/*') as $f) {
|
||||
@@ -719,7 +719,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
|
||||
// Extract uploads
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
@@ -882,7 +882,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
|
||||
if ($attStatus === 200) {
|
||||
$attachments = json_decode($attResponse, true);
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
foreach ($attachments ?? [] as $att) {
|
||||
$fn = basename($att['filename']);
|
||||
@@ -1009,7 +1009,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
// the sender's secret and re-encrypt with the recipient's secret.
|
||||
$cardBody = $card['body'] ?? '';
|
||||
preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches);
|
||||
$uploadDir = __DIR__ . '/uploads';
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
foreach (array_unique($attMatches[1] ?? []) as $filename) {
|
||||
$filePath = $uploadDir . '/' . $filename;
|
||||
if (!file_exists($filePath)) continue;
|
||||
@@ -1320,7 +1320,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
|
||||
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="shortcuts-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-keyboard text-sm"></i><span class="font-pixel text-[10px]">Keys</span></button>
|
||||
<button id="help-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-question text-sm"></i><span class="font-pixel text-[10px]">Help</span></button>
|
||||
@@ -1358,7 +1358,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-info text-sm"></i>concepts</h3>
|
||||
<div class="text-xs leading-relaxed space-y-3 text-t-muted">
|
||||
<p><i class="ph ph-cards text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">cards</span> are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.</p>
|
||||
<p><i class="ph ph-folder text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">buckets</span> organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: <i class="ph ph-squares-four text-xs"></i> <span class="text-t-dim font-pixel">all</span>, <i class="ph ph-lightning text-xs"></i> <span class="text-t-dim font-pixel">current</span>, <i class="ph ph-star text-xs"></i> <span class="text-t-dim font-pixel">favorites</span>, <i class="ph ph-notebook text-xs"></i> <span class="text-t-dim font-pixel">journal</span>, <i class="ph ph-tray text-xs"></i> <span class="text-t-dim font-pixel">unsorted</span>. Drag a card onto a bucket tab to move it. Drag to <i class="ph ph-plus text-xs"></i> to create a new bucket.</p>
|
||||
<p><i class="ph ph-folder text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">buckets</span> organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: <i class="ph ph-squares-four text-xs"></i> <span class="text-t-dim font-pixel">all</span>, <i class="ph ph-lightning text-xs"></i> <span class="text-t-dim font-pixel">current</span>, <i class="ph ph-star text-xs"></i> <span class="text-t-dim font-pixel">favorites</span>, <i class="ph ph-notebook text-xs"></i> <span class="text-t-dim font-pixel">journal</span>, <i class="ph ph-tray text-xs"></i> <span class="text-t-dim font-pixel">unsorted</span>. Drag a card onto a bucket tab to move it. Drag to <i class="ph ph-plus text-xs"></i> to create a new bucket. Buckets can be nested one level deep — drag a bucket onto another to create a <span class="text-t-bright font-pixel">super-bucket</span> with a dropdown for its children.</p>
|
||||
<p><i class="ph ph-stack text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">groups</span> let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use <i class="ph ph-stack text-xs"></i> "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use <i class="ph ph-arrow-line-up text-xs"></i> "dissolve group" to ungroup all at once.</p>
|
||||
<p><i class="ph ph-hash text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">tags</span> are written as <span class="text-t-accent font-pixel">#tagname</span> in the card body and extracted automatically. They support hierarchy: <span class="text-t-accent font-pixel">#project/frontend</span>. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > <i class="ph ph-hash text-xs"></i> tags. Each segment of a hierarchical tag is independently clickable.</p>
|
||||
<p><i class="ph ph-link text-t-accent text-sm"></i> <span class="text-t-bright font-pixel">links</span> connect cards. Write <span class="text-t-accent font-pixel">[[Card Title]]</span> in the body to create a link. Linked cards are clickable in preview and show up as <i class="ph ph-arrow-bend-left-up text-xs"></i> backlinks on the target card. The <i class="ph ph-split-horizontal text-xs"></i> split view shows related cards based on shared tags, links, and content overlap.</p>
|
||||
@@ -1376,12 +1376,30 @@ if (str_starts_with($route, '/api/')) {
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">click card</td><td><i class="ph ph-eye text-xs"></i> open preview (or side panel in split mode)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">double-click</td><td><i class="ph ph-pencil-simple text-xs"></i> open editor</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">right-click</td><td><i class="ph ph-list text-xs"></i> context menu (color, bucket, tags, ...)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">right-click</td><td><i class="ph ph-list text-xs"></i> context menu (color, bucket, tags, copy text, mark done, join, ...)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click</td><td><i class="ph ph-check-square text-xs"></i> multi-select cards</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">drag card</td><td><i class="ph ph-arrows-out-cardinal text-xs"></i> reorder, move to bucket, or group</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrow-up text-xs"></i><i class="ph ph-arrow-down text-xs"></i><i class="ph ph-arrow-left text-xs"></i><i class="ph ph-arrow-right text-xs"></i></td><td>navigate cards with arrow keys, Enter to open</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">click tag</td><td><i class="ph ph-funnel text-xs"></i> filter by tag</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+click tag</td><td><i class="ph ph-plus-circle text-xs"></i> add/remove tag filter (AND)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">group badge</td><td><i class="ph ph-stack text-xs"></i> expand group</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+N</td><td><i class="ph ph-lightning text-xs"></i> quick capture — create card in active bucket</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">1–9</td><td><i class="ph ph-folder text-xs"></i> switch to bucket by number</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-list text-sm"></i>context menu</h3>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-palette text-xs"></i> color</td><td>change card color</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-folder text-xs"></i> move</td><td>move to a different bucket</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> tags</td><td>assign tags via picker</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> copy text</td><td>copy card body as plain text (strips markdown)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> mark done</td><td>toggle #done tag on any card</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-unite text-xs"></i> join cards</td><td>merge multi-selected cards into one</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-copy text-xs"></i> duplicate</td><td>duplicate card</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-md text-xs"></i> save .md</td><td>download as markdown + images</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-trash text-xs"></i> delete</td><td>delete card (with undo)</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1402,11 +1420,14 @@ if (str_starts_with($route, '/api/')) {
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+S</td><td><i class="ph ph-floppy-disk text-xs"></i> save and close</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+H</td><td><i class="ph ph-magnifying-glass text-xs"></i> find + replace in card</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+F</td><td><i class="ph ph-magnifying-glass text-xs"></i> find in card</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>move line up/down</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Shift+Alt+<i class="ph ph-arrow-up text-xs"></i>/<i class="ph ph-arrow-down text-xs"></i></td><td>duplicate line</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Tab</td><td>indent (4 spaces)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">paste / drag</td><td><i class="ph ph-upload-simple text-xs"></i> upload and insert image</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">paste / drag</td><td><i class="ph ph-upload-simple text-xs"></i> upload image, audio, or PDF</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-scissors text-xs"></i> extract</td><td>selection to new card</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-file-pdf text-xs"></i> PDF</td><td>upload + link a PDF from toolbar</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">drag divider</td><td>resize editor / preview split</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
@@ -1464,6 +1485,9 @@ if (str_starts_with($route, '/api/')) {
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag</span> word</td><td>tag filter + text search combined</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><span class="text-t-accent">#tag1 #tag2</span></td><td><i class="ph ph-intersect text-xs"></i> multiple tags (AND)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">bucket filter</td><td><i class="ph ph-folder text-xs"></i> dropdown to restrict scope</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-swap text-xs"></i> Replace</td><td>toggle replace mode — bulk find + replace across all matching cards</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Shift+H</td><td><i class="ph ph-swap text-xs"></i> open search directly in replace mode</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">drag result</td><td><i class="ph ph-stack text-xs"></i> drag onto another card to group</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1506,12 +1530,30 @@ if (str_starts_with($route, '/api/')) {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-layout text-sm"></i>views</h3>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-layout text-sm"></i>views & toolbar</h3>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-split-horizontal text-xs"></i> split</td><td>side-by-side board + preview panel</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list text-xs"></i> superview</td><td>compact multi-column title list with hover cards</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-check-circle text-xs"></i> hide done</td><td>toggle cards tagged #done on/off</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-calendar-x text-xs"></i> hide termine</td><td>toggle appointment cards on/off</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-hash text-xs"></i> hide tagged</td><td>toggle tagged cards on/off</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-sort-ascending text-xs"></i> sort</td><td>manual / newest / oldest / alpha / tag</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-images text-xs"></i> assets</td><td>gallery of all images, audio, and PDFs across cards — with orphan detection</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-broadcast text-sm"></i>relay</h3>
|
||||
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
|
||||
<p>The <span class="text-t-bright font-pixel">relay</span> is an optional self-hosted server that connects multiple hyprcrd instances. Configure the relay URL and your client secret in <i class="ph ph-gear text-xs"></i> Settings > Relay. The relay has its own admin UI for user management.</p>
|
||||
</div>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-folders text-xs"></i> collab-buckets</td><td>shared buckets between tenants — exclusive card locking, unread indicators, E2E encrypted sync</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-chat-circle-dots text-xs"></i> chat</td><td>encrypted chat between tenants — footer flyout, delivery + read receipts</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-paper-plane-tilt text-xs"></i> compose</td><td>send messages to other tenants via the relay inbox</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-tray text-xs"></i> inbox</td><td>read received messages — synced badge shows delivery status</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-lock text-xs"></i> card locking</td><td>editing a collab card locks it for all others — takeover on first keystroke</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-key text-xs"></i> client secret</td><td>per-tenant E2E key — set in relay account settings, shared across devices</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -1888,13 +1930,26 @@ if (str_starts_with($route, '/api/')) {
|
||||
<!-- Search -->
|
||||
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
||||
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
||||
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||
</button>
|
||||
<div class="flex flex-col border-b border-t-border">
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
||||
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
||||
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace">
|
||||
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
||||
</button>
|
||||
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="search-replace-row" class="hidden flex items-center gap-3 px-4 pb-3">
|
||||
<i class="ph ph-swap text-t-dim text-base"></i>
|
||||
<input type="text" id="search-replace-input" placeholder="replace with..." class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1.5 font-mono focus:border-t-accent focus:outline-none rounded-sm" autocomplete="off" spellcheck="false">
|
||||
<span id="search-replace-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<button id="search-replace-all" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors font-pixel text-[10px]" disabled>
|
||||
<i class="ph ph-swap text-sm"></i> Replace all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div id="search-results" class="overflow-y-auto w-2/5 min-w-[220px] border-r border-t-border"></div>
|
||||
@@ -2148,6 +2203,8 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
|
||||
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
|
||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||
<div class="flex-1"></div>
|
||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||
@@ -2155,9 +2212,28 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
||||
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden" style="flex:1 1 0%;min-width:0;">
|
||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden flex flex-col" style="flex:1 1 0%;min-width:0;">
|
||||
<!-- Find / Replace bar (Ctrl+H) -->
|
||||
<div id="editor-find-bar" class="hidden shrink-0 border-b border-t-border bg-t-surface px-3 py-2" style="z-index:5;">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
|
||||
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
|
||||
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button>
|
||||
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button>
|
||||
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button>
|
||||
</div>
|
||||
<div id="replace-row" class="flex items-center gap-2">
|
||||
<i class="ph ph-swap text-xs text-t-dim"></i>
|
||||
<input id="replace-input" type="text" placeholder="replace…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||
<button id="replace-one" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Replace</button>
|
||||
<button id="replace-all" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||
|
||||
+5
-1
@@ -9,7 +9,7 @@ import './modules/theme.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';
|
||||
@@ -39,6 +39,7 @@ 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).
|
||||
@@ -238,6 +239,9 @@ registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<
|
||||
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) => {
|
||||
|
||||
@@ -362,6 +362,7 @@ export function closeModal({ animate = true } = {}) {
|
||||
hideLinkAc();
|
||||
hideTagAc();
|
||||
hideSnippetAc();
|
||||
closeFindBar();
|
||||
modal.classList.remove('open');
|
||||
state.editingId = null;
|
||||
state.returnToGalleryAfterEdit = false;
|
||||
@@ -989,6 +990,123 @@ 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) {
|
||||
|
||||
@@ -88,11 +88,15 @@ export async function openGallery() {
|
||||
// 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.
|
||||
const [files, cards] = await Promise.all([
|
||||
api('api/uploads'),
|
||||
api('api/cards'),
|
||||
]);
|
||||
galleryCards = cards || [];
|
||||
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');
|
||||
|
||||
+92
-10
@@ -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,20 +12,59 @@ 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();
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -35,10 +75,11 @@ export function closeSearch() {
|
||||
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() {
|
||||
@@ -90,7 +131,7 @@ function renderHome() {
|
||||
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) {
|
||||
@@ -115,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 cursor-blink">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 => {
|
||||
@@ -146,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 cursor-blink">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) {
|
||||
@@ -251,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);
|
||||
});
|
||||
@@ -87,7 +87,18 @@ export const guards = {
|
||||
|
||||
// ── 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;
|
||||
@@ -102,6 +113,10 @@ document.addEventListener('keydown', (e) => {
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -20,16 +20,24 @@ function selectIds(ids, { bulk = false } = {}) {
|
||||
for (const id of ids) state.selectedIds.add(String(id));
|
||||
}
|
||||
|
||||
async function clickJoin(rightClickTargetEl) {
|
||||
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. That's 3+ serial API calls and a full board
|
||||
// re-render, so 400ms was sometimes too tight on slower runs.
|
||||
await sleep(800);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -109,7 +117,7 @@ describe('Card: Join — sequential selection (first wins)', () => {
|
||||
if (!firstEl) return;
|
||||
selectIds([first.id, second.id, third.id], { bulk: false });
|
||||
|
||||
const ok = await clickJoin(firstEl);
|
||||
const ok = await clickJoin(firstEl, first.id);
|
||||
if (!ok) return;
|
||||
|
||||
// Winner survives
|
||||
@@ -132,7 +140,7 @@ describe('Card: Join — sequential selection (first wins)', () => {
|
||||
if (!aEl) return;
|
||||
selectIds([a.id, b.id, c.id], { bulk: false });
|
||||
|
||||
const ok = await clickJoin(aEl);
|
||||
const ok = await clickJoin(aEl, a.id);
|
||||
if (!ok) return;
|
||||
|
||||
const survivor = await getCard(a.id);
|
||||
@@ -159,7 +167,7 @@ describe('Card: Join — sequential selection (first wins)', () => {
|
||||
if (!aEl) return;
|
||||
selectIds([a.id, b.id], { bulk: false });
|
||||
|
||||
const ok = await clickJoin(aEl);
|
||||
const ok = await clickJoin(aEl, a.id);
|
||||
if (!ok) return;
|
||||
|
||||
expect(state.selectedIds.size).toBe(0);
|
||||
@@ -187,7 +195,7 @@ describe('Card: Join — bulk selection (longest title wins)', () => {
|
||||
// join code uses longest-title strategy.
|
||||
selectIds([short.id, longer.id, medium.id], { bulk: true });
|
||||
|
||||
const ok = await clickJoin(shortEl);
|
||||
const ok = await clickJoin(shortEl, longer.id);
|
||||
if (!ok) return;
|
||||
|
||||
// The longest-titled card should be the survivor
|
||||
@@ -220,7 +228,7 @@ describe('Card: Join — undo', () => {
|
||||
if (!winnerEl) return;
|
||||
selectIds([winner.id, loser.id], { bulk: false });
|
||||
|
||||
const ok = await clickJoin(winnerEl);
|
||||
const ok = await clickJoin(winnerEl, winner.id);
|
||||
if (!ok) return;
|
||||
|
||||
// Sanity: loser is gone, winner has merged body
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('Card: Make ticket (gitea)', () => {
|
||||
// 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 < 40; i++) {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
if (titleInput?.value === title) break;
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('Relay: Push API', () => {
|
||||
});
|
||||
|
||||
it('push without card_id returns error', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (!settings.url || !settings.key) return; // card_id check is after config check
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({}),
|
||||
|
||||
@@ -17,6 +17,7 @@ const FILES = [
|
||||
'./context-menu.test.js',
|
||||
'./card-interactions.test.js',
|
||||
'./card-join.test.js',
|
||||
'./board-find-replace.test.js',
|
||||
'./filtering.test.js',
|
||||
'./sorting.test.js',
|
||||
'./state-functions.test.js',
|
||||
|
||||
+116
-35
@@ -9,6 +9,28 @@ function $(id) { return document.getElementById(id); }
|
||||
* Multiple toasts stack vertically, oldest at the top, and each one
|
||||
* slides in from the right + fades out after a fixed timeout. The
|
||||
* legacy `toast(msg, true)` second-arg style still works (true → err). */
|
||||
/** Custom confirm dialog — returns a Promise<boolean>. Replaces native
|
||||
* confirm() across the relay UI so all confirmations are styled. */
|
||||
function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) {
|
||||
return new Promise(resolve => {
|
||||
const el = $('relay-confirm');
|
||||
if (!el) { resolve(confirm(msg)); return; } // fallback
|
||||
$('relay-confirm-msg').textContent = msg;
|
||||
const yesBtn = $('relay-confirm-yes');
|
||||
yesBtn.textContent = okLabel;
|
||||
yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
|
||||
el.classList.remove('hidden');
|
||||
const cleanup = (result) => {
|
||||
el.classList.add('hidden');
|
||||
yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners
|
||||
$('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true));
|
||||
resolve(result);
|
||||
};
|
||||
$('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true });
|
||||
$('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, kind = 'info') {
|
||||
if (kind === true) kind = 'err';
|
||||
if (kind === false) kind = 'info';
|
||||
@@ -266,31 +288,53 @@ async function loadTenants() {
|
||||
}
|
||||
|
||||
function populateRecipientPicker() {
|
||||
const sel = $('compose-recipient');
|
||||
const field = $('compose-recipient-field');
|
||||
sel.innerHTML = '';
|
||||
const hidden = $('compose-recipient');
|
||||
const btn = $('compose-recipient-btn');
|
||||
const label = $('compose-recipient-label');
|
||||
const dd = $('compose-recipient-dropdown');
|
||||
const field = $('compose-recipient-field');
|
||||
if (!hidden || !btn || !dd) return;
|
||||
|
||||
// Always include "you" first as the default recipient (the value
|
||||
// the form submits even when the picker is hidden)
|
||||
if (me) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = me.username;
|
||||
opt.textContent = `${me.username} (you)`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Build the options list: self first, then all other tenants
|
||||
const options = [];
|
||||
if (me) options.push({ value: me.username, text: `${me.username} (you)` });
|
||||
for (const t of allTenants) {
|
||||
if (me && t.username === me.username) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.username;
|
||||
opt.textContent = t.username;
|
||||
sel.appendChild(opt);
|
||||
options.push({ value: t.username, text: t.username });
|
||||
}
|
||||
|
||||
// Solo tenant → no choice to make. Hide the picker entirely so
|
||||
// the compose form is just title + body.
|
||||
if (field) {
|
||||
field.classList.toggle('hidden', allTenants.length <= 1);
|
||||
// Default to self
|
||||
if (options.length && !hidden.value) {
|
||||
hidden.value = options[0].value;
|
||||
label.textContent = options[0].text;
|
||||
}
|
||||
|
||||
// Render dropdown items
|
||||
dd.innerHTML = options.map(o => `
|
||||
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
|
||||
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
dd.querySelectorAll('.recipient-option').forEach(opt => {
|
||||
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)');
|
||||
opt.addEventListener('mouseleave', () => opt.style.background = 'none');
|
||||
opt.addEventListener('click', () => {
|
||||
hidden.value = opt.dataset.value;
|
||||
label.textContent = opt.textContent.trim();
|
||||
dd.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle dropdown on button click
|
||||
btn.onclick = () => dd.classList.toggle('hidden');
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Solo tenant → hide the picker
|
||||
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
|
||||
}
|
||||
|
||||
function clearCompose() {
|
||||
@@ -337,6 +381,7 @@ async function loadInbox() {
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
$('inbox-count').textContent = messages.length;
|
||||
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
||||
// Mirror count into the header status so the unread dot stays in sync
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||
@@ -353,9 +398,8 @@ async function loadInbox() {
|
||||
list.innerHTML = messages.map(m => `
|
||||
<div class="inbox-item" data-id="${m.id}">
|
||||
<div class="content">
|
||||
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? '' : ' <span class="badge-new">new</span>'}</div>
|
||||
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
|
||||
<div class="body">${escapeHtml(m.body)}</div>
|
||||
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
|
||||
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
||||
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||
@@ -364,19 +408,56 @@ async function loadInbox() {
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn) {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||
if (res?.ok) {
|
||||
btn.closest('.inbox-item')?.remove();
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
toast(res?.error || 'delete failed', 'err');
|
||||
}
|
||||
// Inline confirmation: replace the trash button with yes/no.
|
||||
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||
// passing escaped HTML through attributes.
|
||||
const row = btn.closest('.inbox-item');
|
||||
if (!row) return;
|
||||
const origHtml = btn.outerHTML;
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'del-confirm';
|
||||
wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
|
||||
wrap.innerHTML = `
|
||||
<span style="color:var(--danger);">delete?</span>
|
||||
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
|
||||
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
|
||||
`;
|
||||
btn.replaceWith(wrap);
|
||||
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||
if (res?.ok) {
|
||||
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
toast(res?.error || 'delete failed', 'err');
|
||||
}
|
||||
});
|
||||
wrap.querySelector('.del-no').addEventListener('click', () => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = origHtml;
|
||||
wrap.replaceWith(temp.firstChild);
|
||||
});
|
||||
}
|
||||
window.deleteMessage = deleteMessage;
|
||||
|
||||
async function deleteAllMessages() {
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
if (!messages.length) return;
|
||||
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
|
||||
let ok = 0;
|
||||
for (const m of messages) {
|
||||
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
|
||||
if (r?.ok) ok++;
|
||||
}
|
||||
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
}
|
||||
window.deleteAllMessages = deleteAllMessages;
|
||||
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
@@ -466,7 +547,7 @@ async function resetUserPassword(id, username) {
|
||||
}
|
||||
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
|
||||
if (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) return;
|
||||
const res = await api('api/users/' + id, { method: 'DELETE' });
|
||||
if (res.ok) { toast('user deleted'); loadUsers(); }
|
||||
else toast(res.error || 'failed', true);
|
||||
@@ -533,7 +614,7 @@ async function saveAccountSecret() {
|
||||
|
||||
async function generateAccountSecret() {
|
||||
if (currentAccountSecret) {
|
||||
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
|
||||
if (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) return;
|
||||
}
|
||||
setAccountStatus('generating...');
|
||||
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
||||
@@ -550,7 +631,7 @@ async function generateAccountSecret() {
|
||||
}
|
||||
|
||||
async function clearAccountSecret() {
|
||||
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
|
||||
if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) return;
|
||||
const res = await api('api/account/secret', { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setAccountStatus('cleared', 'ok');
|
||||
|
||||
+23
-2
@@ -74,7 +74,14 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
|
||||
<div class="field" id="compose-recipient-field">
|
||||
<label>recipient</label>
|
||||
<select id="compose-recipient"></select>
|
||||
<div id="compose-recipient-wrap" style="position:relative;">
|
||||
<button id="compose-recipient-btn" type="button" class="input" style="text-align:left;cursor:pointer;display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="compose-recipient-label">select…</span>
|
||||
<i class="ph ph-caret-down" style="font-size:10px;color:var(--dim);"></i>
|
||||
</button>
|
||||
<input type="hidden" id="compose-recipient" value="">
|
||||
<div id="compose-recipient-dropdown" class="hidden" style="position:absolute;top:100%;left:0;right:0;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 8px 24px rgba(0,0,0,0.4);z-index:50;max-height:200px;overflow-y:auto;padding:4px 0;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>title (optional)</label>
|
||||
@@ -92,7 +99,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</div>
|
||||
</div>
|
||||
<div id="inbox-section" class="section">
|
||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span></h2>
|
||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||
<span style="flex:1"></span>
|
||||
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
|
||||
</h2>
|
||||
<div id="inbox-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,6 +195,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Confirm dialog (replaces native confirm()) -->
|
||||
<div id="relay-confirm" class="hidden" style="position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:24px;">
|
||||
<div style="background:var(--surface);border:1px solid var(--border);border-radius:6px;box-shadow:0 16px 48px rgba(0,0,0,0.5);padding:24px;max-width:380px;width:100%;text-align:center;">
|
||||
<p id="relay-confirm-msg" style="font-size:12px;color:var(--text);line-height:1.5;margin-bottom:18px;"></p>
|
||||
<div style="display:flex;gap:8px;justify-content:center;">
|
||||
<button id="relay-confirm-no" class="btn btn-secondary">Cancel</button>
|
||||
<button id="relay-confirm-yes" class="btn btn-danger">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-stack"></div>
|
||||
|
||||
<script src="ui/app.js"></script>
|
||||
|
||||
@@ -326,6 +326,16 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
margin-left:4px;
|
||||
}
|
||||
.badge-synced {
|
||||
display:inline-flex; align-items:center; gap:3px;
|
||||
color:var(--ok, #4ade80);
|
||||
padding:1px 6px; border-radius:var(--radius);
|
||||
border:1px solid color-mix(in srgb, var(--ok, #4ade80) 40%, var(--border));
|
||||
font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
margin-left:4px;
|
||||
}
|
||||
.badge-synced .ph { font-size:9px; }
|
||||
.inbox-item .del {
|
||||
background:none; border:none; color:var(--dim); cursor:pointer;
|
||||
padding:6px; font-size:14px; flex-shrink:0;
|
||||
|
||||
Reference in new issue
Block a user