Compare commits
21
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 |
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.7');
|
||||
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';
|
||||
+122
-1
@@ -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 {
|
||||
@@ -457,6 +462,11 @@ body::after {
|
||||
}
|
||||
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||
.card-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;
|
||||
@@ -469,6 +479,14 @@ 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;
|
||||
@@ -743,6 +761,63 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
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. */
|
||||
@@ -759,6 +834,51 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
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 {
|
||||
display: inline-flex; align-items: center; padding: 1px 6px;
|
||||
@@ -792,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 {
|
||||
|
||||
+124
-29
@@ -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) {
|
||||
@@ -454,8 +454,8 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
|
||||
// Check MIME — finfo first, fallback to extension for audio
|
||||
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg'];
|
||||
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a'];
|
||||
$mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf'];
|
||||
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf'];
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$realMime = $finfo->file($file['tmp_name']);
|
||||
$ext = $mimeMap[$realMime] ?? null;
|
||||
@@ -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;
|
||||
@@ -1236,7 +1236,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
|
||||
</button>
|
||||
<button id="gallery-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">Images</span>
|
||||
<i class="ph ph-folder-open text-sm"></i><span class="font-pixel text-[10px]">Assets</span>
|
||||
</button>
|
||||
<button id="tasks-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
|
||||
<i class="ph ph-check-square-offset text-sm"></i><span class="font-pixel text-[10px]">Tasks</span>
|
||||
@@ -1320,12 +1320,29 @@ if (str_starts_with($route, '/api/')) {
|
||||
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Settings</span>
|
||||
</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>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Shortcuts (?) -->
|
||||
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="shortcuts">
|
||||
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-4xl max-h-full overflow-y-auto">
|
||||
<div class="flex items-center gap-3 px-6 py-4 border-b border-t-border sticky top-0 bg-t-surface z-[1]">
|
||||
<i class="ph ph-keyboard text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-sm text-t-accent">keyboard shortcuts</h2>
|
||||
<div class="flex-1"></div>
|
||||
<button id="shortcuts-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||
</div>
|
||||
<!-- Filled by shortcuts.js renderShortcutHelp() from the registry.
|
||||
Layout: 3 columns on desktop, 2 on tablet, 1 on mobile.
|
||||
The CSS grid is set on .shortcut-help-content. -->
|
||||
<div class="shortcut-help-content p-6 text-sm text-t-text" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help -->
|
||||
<div class="overlay fixed inset-0 z-[95] flex justify-center items-start pt-12 pb-12 px-8" style="background:var(--t-overlay)" id="help">
|
||||
<div class="bg-t-surface border border-t-border shadow-2xl w-full max-w-3xl max-h-full overflow-y-auto">
|
||||
@@ -1341,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>
|
||||
@@ -1351,13 +1368,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-keyboard text-sm"></i>shortcuts</h3>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel">Ctrl+K</td><td><i class="ph ph-magnifying-glass text-xs"></i> open / close search</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+N</td><td><i class="ph ph-lightning text-xs"></i> quick capture — type title, Enter to save</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Ctrl+Z</td><td><i class="ph ph-arrow-counter-clockwise text-xs"></i> undo last action</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">F1</td><td><i class="ph ph-question text-xs"></i> open / close help</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel">Escape</td><td><i class="ph ph-x text-xs"></i> close any overlay</td></tr>
|
||||
</table>
|
||||
<p class="text-xs text-t-muted mb-2">Press <kbd class="px-1 py-0.5 border border-t-border rounded text-[9px] font-pixel">?</kbd> for the full keyboard reference.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -1365,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>
|
||||
|
||||
@@ -1391,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>
|
||||
@@ -1453,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>
|
||||
|
||||
@@ -1495,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>
|
||||
|
||||
@@ -1877,14 +1930,27 @@ 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">
|
||||
<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>
|
||||
<div class="flex-1 overflow-y-auto p-5">
|
||||
@@ -2016,18 +2082,24 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Gallery -->
|
||||
<!-- Assets (was Gallery) -->
|
||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
||||
<div class="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>
|
||||
<h2 class="font-pixel text-xs text-t-accent">images</h2>
|
||||
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
||||
<span id="gallery-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||
<div id="gallery-tabs" class="flex items-center gap-1 ml-3">
|
||||
<button class="gallery-tab active font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="all">All</button>
|
||||
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="image">Images</button>
|
||||
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="audio">Audio</button>
|
||||
<button class="gallery-tab font-pixel text-[9px] px-2 py-0.5 border border-t-border text-t-muted transition-colors" data-filter="pdf">PDF</button>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<button id="gallery-upload" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">Upload</span>
|
||||
</button>
|
||||
<input type="file" id="gallery-upload-file" accept="image/*,audio/*" multiple class="hidden">
|
||||
<input type="file" id="gallery-upload-file" accept="image/*,audio/*,.pdf,application/pdf" multiple class="hidden">
|
||||
<button id="gallery-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||
</button>
|
||||
@@ -2129,6 +2201,10 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<button 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>
|
||||
@@ -2136,10 +2212,29 @@ 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-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>
|
||||
</div>
|
||||
|
||||
+181
-92
@@ -9,10 +9,11 @@ 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';
|
||||
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,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).
|
||||
@@ -112,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'; });
|
||||
@@ -193,101 +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')) {
|
||||
// If the user chose "exit zen first", ESC leaves zen mode before closing the card.
|
||||
const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close';
|
||||
if (escAction === 'exitZen' && detail.classList.contains('zen')) {
|
||||
document.getElementById('detail-zen')?.click();
|
||||
return;
|
||||
}
|
||||
closeDetail();
|
||||
}
|
||||
else if (modal.classList.contains('open')) closeModal();
|
||||
else if (search.classList.contains('open')) closeSearch();
|
||||
else if (tasks?.classList.contains('open')) closeTasks();
|
||||
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
|
||||
else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar();
|
||||
return;
|
||||
}
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); 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(); }
|
||||
// Bare "c" toggles the calendar from the board. Same guards as "/":
|
||||
// no modifier, focus on <body> (so it doesn't intercept text typing),
|
||||
// and no full-screen overlay already in front.
|
||||
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const calendar = document.getElementById('calendar');
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
e.preventDefault();
|
||||
if (calendar?.classList.contains('open')) closeCalendar();
|
||||
else openCalendar();
|
||||
return;
|
||||
}
|
||||
// Bare "t" opens the tag quick-pick on the board. Same focus +
|
||||
// overlay guards as "c". Picking a tag sets state.activeTags = [tag].
|
||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
e.preventDefault();
|
||||
openTagQuickPick();
|
||||
return;
|
||||
}
|
||||
// Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters
|
||||
// (all/current/favorites/journal/none) are skipped, super-buckets
|
||||
// count as one slot and activate via activateBucket() so the
|
||||
// dropdown popover is NOT triggered.
|
||||
if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
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 idx = parseInt(e.key, 10) - 1;
|
||||
const tile = tiles[idx];
|
||||
if (tile) {
|
||||
e.preventDefault();
|
||||
// Super-bucket: activate the currently shown member (parent or
|
||||
// active child) directly instead of clicking, which would open
|
||||
// the dropdown popover. Regular bucket: just take its data-bucket.
|
||||
const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket;
|
||||
activateBucket(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// — 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 ──
|
||||
|
||||
@@ -316,9 +398,15 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
body = lines.slice(1).join('\n').trim();
|
||||
}
|
||||
if (!cardTitle) return;
|
||||
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
|
||||
// No auto-tag — the card goes into the active bucket (or unsorted).
|
||||
|
||||
await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1 } });
|
||||
// Place the card in the currently active bucket (if it's a real
|
||||
// user bucket). Special filters (all/current/favorites/journal/none)
|
||||
// don't map to a bucket_id — cards captured there stay unsorted.
|
||||
const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||
const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket;
|
||||
const captured = await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
|
||||
if (captured?.id) state.recentlyCreatedIds?.add(String(captured.id));
|
||||
|
||||
e.target.value = '';
|
||||
document.getElementById('quick-capture').classList.add('hidden');
|
||||
@@ -424,6 +512,7 @@ function openTagQuickPick() {
|
||||
|
||||
// ── Boot ──
|
||||
|
||||
renderShortcutHelp();
|
||||
checkAuth();
|
||||
|
||||
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||
+97
-15
@@ -30,6 +30,62 @@ let dragGhost = null;
|
||||
// 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;
|
||||
const selectBar = $('select-bar');
|
||||
@@ -77,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: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
});
|
||||
// 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 => {
|
||||
@@ -450,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);
|
||||
@@ -659,6 +721,9 @@ export function renderBoard() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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}"]`);
|
||||
@@ -686,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 ──
|
||||
|
||||
@@ -242,6 +242,12 @@ 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`;
|
||||
@@ -253,19 +259,42 @@ function renderBar() {
|
||||
.filter(b => b.parent_id == parentId)
|
||||
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||
|
||||
// Resolve which child / parent to *show* on a super-bucket tile. The
|
||||
// active bucket wins; otherwise fall back to the parent itself.
|
||||
// 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>
|
||||
@@ -363,9 +392,13 @@ function renderBar() {
|
||||
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;
|
||||
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>`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -283,9 +283,11 @@ export function showContextMenu(x, y, card) {
|
||||
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();
|
||||
}
|
||||
@@ -354,13 +356,51 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
else if (a === 'delete') {
|
||||
hideContextMenu();
|
||||
const children = getChildren(card.id);
|
||||
const isGroup = children.length > 0;
|
||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||
if (needConfirm) {
|
||||
if (isGroup) {
|
||||
// Always confirm when deleting a group — list every child
|
||||
// so the user knows exactly what they're losing.
|
||||
const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n');
|
||||
const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`;
|
||||
const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` });
|
||||
if (!ok) return;
|
||||
} else if (needConfirm) {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
||||
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?.();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+140
-5
@@ -362,6 +362,7 @@ export function closeModal({ animate = true } = {}) {
|
||||
hideLinkAc();
|
||||
hideTagAc();
|
||||
hideSnippetAc();
|
||||
closeFindBar();
|
||||
modal.classList.remove('open');
|
||||
state.editingId = null;
|
||||
state.returnToGalleryAfterEdit = false;
|
||||
@@ -370,6 +371,11 @@ export function closeModal({ animate = true } = {}) {
|
||||
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() {
|
||||
@@ -410,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();
|
||||
@@ -983,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();
|
||||
@@ -1004,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);
|
||||
@@ -1027,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);
|
||||
@@ -1041,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;
|
||||
@@ -1049,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 => {
|
||||
|
||||
+115
-17
@@ -1,44 +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;
|
||||
}
|
||||
@@ -49,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');
|
||||
@@ -66,21 +113,62 @@ 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>
|
||||
@@ -92,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">
|
||||
@@ -117,13 +205,16 @@ export async function openGallery() {
|
||||
});
|
||||
});
|
||||
|
||||
// Orphan: click to enlarge
|
||||
// Orphan: click to enlarge (images) or open in new tab (PDFs)
|
||||
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +225,11 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +260,9 @@ export async function openGallery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the active filter (persists across re-opens within a session)
|
||||
applyFilter(activeFilter);
|
||||
|
||||
el.classList.add('open');
|
||||
}
|
||||
|
||||
|
||||
+98
-11
@@ -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() {
|
||||
@@ -85,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) {
|
||||
@@ -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 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 => {
|
||||
@@ -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 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) {
|
||||
@@ -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, '>');
|
||||
}
|
||||
@@ -32,6 +32,16 @@ export const state = {
|
||||
// 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,
|
||||
|
||||
@@ -258,6 +258,14 @@ function refreshPresetSelect() {
|
||||
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() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, csrf } from './runner.js?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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');
|
||||
@@ -92,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)');
|
||||
});
|
||||
});
|
||||
@@ -259,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');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
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 = '') {
|
||||
@@ -20,13 +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);
|
||||
await sleep(400);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -106,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
|
||||
@@ -129,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);
|
||||
@@ -156,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);
|
||||
@@ -184,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
|
||||
@@ -217,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, csrf } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, csrf } from './runner.js?v=4';
|
||||
|
||||
describe('Context Menu', () => {
|
||||
afterEach(async () => {
|
||||
@@ -203,9 +203,15 @@ describe('Card: Make ticket (gitea)', () => {
|
||||
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.
|
||||
// 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||
|
||||
function enableLines() {
|
||||
const gutter = $('#editor-gutter');
|
||||
|
||||
@@ -39,7 +39,7 @@ document.addEventListener('keydown', e => {
|
||||
|
||||
// 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=2';
|
||||
const RUNNER_V = '?v=4';
|
||||
|
||||
function ensureProgressBar() {
|
||||
if (document.getElementById('test-progress')) return;
|
||||
@@ -49,6 +49,21 @@ function ensureProgressBar() {
|
||||
// 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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from './runner.js?v=2';
|
||||
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?v=2';
|
||||
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?v=2';
|
||||
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Multi-Select', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, $ } from './runner.js?v=2';
|
||||
import { describe, it, expect, $ } from './runner.js?v=4';
|
||||
|
||||
describe('Notification Banner', () => {
|
||||
it('banner element exists', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
|
||||
describe('Quick Capture: Open/Close', () => {
|
||||
afterEach(async () => {
|
||||
@@ -46,7 +46,7 @@ describe('Quick Capture: Card Creation', () => {
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('Enter creates card with #inbox tag', async () => {
|
||||
it('Enter creates card in active bucket', async () => {
|
||||
pressKey('n', { ctrlKey: true });
|
||||
await sleep(100);
|
||||
const input = $('#quick-capture-input');
|
||||
@@ -57,7 +57,6 @@ describe('Quick Capture: Card Creation', () => {
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const found = cards.find(c => c.title === '__test_quick_capture__');
|
||||
expect(found).toExist();
|
||||
expect(found.tags).toContain('inbox');
|
||||
});
|
||||
|
||||
it('capture bar closes after Enter', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, csrf } from './runner.js?v=2';
|
||||
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||
|
||||
describe('Relay: Push API', () => {
|
||||
it('push without config returns error', async () => {
|
||||
@@ -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({}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from './runner.js?v=2';
|
||||
import { describe, it, expect } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
||||
|
||||
@@ -171,7 +171,7 @@ export function csrf() {
|
||||
}
|
||||
|
||||
export async function closeAll() {
|
||||
for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks']) {
|
||||
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||
}
|
||||
@@ -179,6 +179,8 @@ export async function closeAll() {
|
||||
document.querySelector('#select-bar')?.classList.add('hidden');
|
||||
document.querySelector('.bucket-picker')?.remove();
|
||||
document.querySelector('.orphan-lightbox')?.remove();
|
||||
document.getElementById('bucket-super-popover')?.remove();
|
||||
document.getElementById('gitea-overlay')?.classList.remove('open');
|
||||
// Clear multi-select
|
||||
document.querySelectorAll('.card-selected').forEach(el => {
|
||||
el.classList.remove('card-selected');
|
||||
@@ -234,9 +236,7 @@ function updateProgress(current, total, name, failed) {
|
||||
}
|
||||
function hideProgress() {
|
||||
getProgressBar()?.remove();
|
||||
// ensureProgressBar() in loader.js pushed the document down by 30px and
|
||||
// shrunk it by the same amount so the footer wouldn't get clipped.
|
||||
// Restore both inline overrides here.
|
||||
document.getElementById('test-shield')?.remove();
|
||||
document.body.style.marginTop = '';
|
||||
document.body.style.height = '';
|
||||
}
|
||||
@@ -281,13 +281,15 @@ async function runAll(only = null) {
|
||||
if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] });
|
||||
suiteMap.get(suite.name).tests.push(entry);
|
||||
}
|
||||
hideProgress();
|
||||
// Don't hide the progress bar here — run() does it after the retry
|
||||
// pass (if any) so the bar stays visible during the whole sequence.
|
||||
return [...suiteMap.values()];
|
||||
}
|
||||
|
||||
// ── UI Panel ──
|
||||
|
||||
let panel = null;
|
||||
let lastResults = null;
|
||||
|
||||
function createPanel() {
|
||||
if (panel) { panel.remove(); panel = null; }
|
||||
@@ -308,9 +310,35 @@ function createPanel() {
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
||||
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
||||
// Copy: when failures exist, only copy the failed + flaky tests so
|
||||
// the user can paste the relevant part without wading through 200+
|
||||
// lines of green. When everything passed, copy the full output.
|
||||
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const text = output.innerText;
|
||||
let text;
|
||||
if (lastResults) {
|
||||
const hasFailures = lastResults.some(s => s.tests.some(t => t.status === 'fail' || t.retried));
|
||||
if (hasFailures) {
|
||||
const lines = [];
|
||||
for (const s of lastResults) {
|
||||
const relevant = s.tests.filter(t => t.status === 'fail' || t.retried);
|
||||
if (!relevant.length) continue;
|
||||
lines.push(s.name);
|
||||
for (const t of relevant) {
|
||||
const icon = t.status === 'fail' ? '✗' : '⚠';
|
||||
const note = t.retried ? (t.status === 'pass' ? ' (flaky — passed on retry)' : ' (failed twice)') : '';
|
||||
lines.push(`${icon} ${t.name} ${t.time}ms${note}`);
|
||||
if (t.error) lines.push(` ${t.error}`);
|
||||
if (t.originalError) lines.push(` first run: ${t.originalError}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
text = lines.join('\n').trim();
|
||||
} else {
|
||||
text = panel.querySelector('#test-output').innerText;
|
||||
}
|
||||
} else {
|
||||
text = panel.querySelector('#test-output').innerText;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = panel.querySelector('#test-copy-btn');
|
||||
btn.textContent = 'copied';
|
||||
@@ -321,6 +349,7 @@ function createPanel() {
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
lastResults = results;
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
let total = 0, passed = 0, failed = 0, flaky = 0, totalTime = 0;
|
||||
@@ -425,7 +454,10 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clean up any leftover UI state
|
||||
// 5. Hide progress bar now that both passes (first + retry) are done
|
||||
hideProgress();
|
||||
|
||||
// 6. Clean up any leftover UI state
|
||||
await closeAll();
|
||||
|
||||
// 6. Restore backup
|
||||
@@ -464,4 +496,9 @@ document.addEventListener('keydown', e => {
|
||||
e.preventDefault();
|
||||
panel ? close() : open();
|
||||
}
|
||||
if (e.key === 'Escape' && panel && panel.style.display !== 'none') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
|
||||
describe('Search: Tag + Text Combined', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js?v=4';
|
||||
|
||||
describe('Search', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=4';
|
||||
|
||||
describe('Settings: Open/Close', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, $ } from './runner.js?v=2';
|
||||
import { describe, it, expect, $ } from './runner.js?v=4';
|
||||
|
||||
describe('App Shell', () => {
|
||||
it('header with logo', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||
|
||||
// Helper: simulate typing a snippet trigger in the editor body
|
||||
function typeSnippet(bodyEl, trigger) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, csrf } from './runner.js?v=2';
|
||||
import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||
import { state, sortedCards } from '../../js/modules/state.js';
|
||||
|
||||
describe('Sorting: All Modes', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from './runner.js?v=2';
|
||||
import { describe, it, expect } from './runner.js?v=4';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../../js/modules/state.js';
|
||||
|
||||
describe('State: parseTags', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
// Helper: create a bucket with the given name and return its row.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, $$, click, closeAll, sleep } from './runner.js?v=4';
|
||||
|
||||
describe('Theme', () => {
|
||||
it('CSS custom properties set', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Toolbar: Filter Toggles', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=2';
|
||||
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
function ctrlZ() {
|
||||
|
||||
@@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
||||
}
|
||||
|
||||
// ── UI inbox view: session reads decrypted messages ──
|
||||
//
|
||||
// The relay already sees plaintext during the routing hop (decrypt with
|
||||
// sender secret → re-encrypt with inter-tenant key for at-rest storage),
|
||||
// so reading them back to the session user is no extra leakage. Lets the
|
||||
// user check their inbox from the relay web UI on a phone without
|
||||
// needing the full hyprcrd client. Does NOT mark messages as fetched —
|
||||
// the canonical receiver is still the hyprcrd client.
|
||||
|
||||
if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200');
|
||||
$stmt->execute(['r' => $sessionTenant['id']]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'sender' => $row['sender_username'] ?? '?',
|
||||
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||
'created_at' => $row['created_at'],
|
||||
'fetched' => (int)$row['fetched'],
|
||||
];
|
||||
}
|
||||
jsonResponse(['messages' => $out]);
|
||||
}
|
||||
|
||||
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||
|
||||
$clientTenant = authClient($db);
|
||||
|
||||
+276
-44
@@ -5,12 +5,51 @@ let allTenants = [];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('err', isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2200);
|
||||
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
|
||||
* 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';
|
||||
const stack = $('toast-stack');
|
||||
if (!stack) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + kind;
|
||||
const icon = kind === 'err' ? 'ph-warning-circle'
|
||||
: kind === 'ok' ? 'ph-check-circle'
|
||||
: 'ph-info';
|
||||
el.innerHTML = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
|
||||
el.querySelector('.toast-msg').textContent = msg;
|
||||
stack.appendChild(el);
|
||||
// Trigger the slide-in next frame so the transition runs
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
el.classList.add('fade-out');
|
||||
setTimeout(() => el.remove(), 350);
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
@@ -29,10 +68,6 @@ function showView(name) {
|
||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||
name = 'change-password';
|
||||
}
|
||||
// Block admin view on mobile — even if someone tries programmatically
|
||||
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
|
||||
name = 'compose';
|
||||
}
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
||||
$('view-' + v)?.classList.add('hidden');
|
||||
});
|
||||
@@ -40,20 +75,28 @@ function showView(name) {
|
||||
|
||||
if (['compose', 'account', 'admin'].includes(name)) {
|
||||
$('nav-buttons').classList.remove('hidden');
|
||||
$('nav-buttons').style.display = 'flex';
|
||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||
$('nav-account').classList.toggle('active', name === 'account');
|
||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||
$('nav-burger')?.classList.remove('hidden');
|
||||
// Picking a view also collapses the mobile menu so it doesn't
|
||||
// hover over the freshly-shown view.
|
||||
$('nav-buttons').classList.remove('menu-open');
|
||||
} else {
|
||||
// Setup, login, and forced change-password hide all nav
|
||||
$('nav-buttons').classList.add('hidden');
|
||||
$('nav-burger')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (name === 'compose') {
|
||||
loadTenants().then(() => populateRecipientPicker());
|
||||
loadInbox();
|
||||
startInboxPoll();
|
||||
$('compose-body')?.focus();
|
||||
} else if (name === 'admin') {
|
||||
} else {
|
||||
stopInboxPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
loadAccountSecret();
|
||||
@@ -63,6 +106,64 @@ function showView(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inbox auto-poll ──
|
||||
//
|
||||
// Refresh the inbox every 15s while the compose view is showing so new
|
||||
// messages from other tenants surface without a manual reload. Stops
|
||||
// the moment the user navigates away (account/admin/login) so we don't
|
||||
// hammer the server in the background.
|
||||
let inboxPollHandle = null;
|
||||
function startInboxPoll() {
|
||||
stopInboxPoll();
|
||||
inboxPollHandle = setInterval(() => {
|
||||
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
|
||||
}, 15000);
|
||||
}
|
||||
function stopInboxPoll() {
|
||||
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
||||
}
|
||||
|
||||
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
||||
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
||||
* positioned under the header. */
|
||||
function toggleNavMenu() {
|
||||
$('nav-buttons')?.classList.toggle('menu-open');
|
||||
}
|
||||
window.toggleNavMenu = toggleNavMenu;
|
||||
|
||||
/** Tap-target on the header inbox count: switches to the compose view
|
||||
* if needed, then smooth-scrolls #inbox-section into view and briefly
|
||||
* highlights it so the user can't miss it after the scroll lands.
|
||||
* Same handler on desktop and mobile — desktop just scrolls a few px
|
||||
* if at all (the inbox is already on screen). */
|
||||
function jumpToInbox() {
|
||||
if (!me) return;
|
||||
if ($('view-compose')?.classList.contains('hidden')) {
|
||||
showView('compose');
|
||||
}
|
||||
// Wait one frame so the (possibly just-shown) view has measurable
|
||||
// dimensions before scrollIntoView fires
|
||||
requestAnimationFrame(() => {
|
||||
const target = $('inbox-section');
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.remove('flash-highlight');
|
||||
// Restart the animation by toggling the class
|
||||
void target.offsetWidth;
|
||||
target.classList.add('flash-highlight');
|
||||
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
||||
});
|
||||
}
|
||||
window.jumpToInbox = jumpToInbox;
|
||||
// Tap outside the menu closes it
|
||||
document.addEventListener('click', (e) => {
|
||||
const nav = $('nav-buttons');
|
||||
const burger = $('nav-burger');
|
||||
if (!nav?.classList.contains('menu-open')) return;
|
||||
if (nav.contains(e.target) || burger?.contains(e.target)) return;
|
||||
nav.classList.remove('menu-open');
|
||||
});
|
||||
|
||||
async function doChangePassword() {
|
||||
const current = $('cp-current').value;
|
||||
const newPw = $('cp-new').value;
|
||||
@@ -172,7 +273,9 @@ async function refreshMe() {
|
||||
};
|
||||
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
||||
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
||||
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
|
||||
const n = status.inbox_count || 0;
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', n > 0);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -185,31 +288,53 @@ async function loadTenants() {
|
||||
}
|
||||
|
||||
function populateRecipientPicker() {
|
||||
const sel = $('compose-recipient');
|
||||
const hidden = $('compose-recipient');
|
||||
const btn = $('compose-recipient-btn');
|
||||
const label = $('compose-recipient-label');
|
||||
const dd = $('compose-recipient-dropdown');
|
||||
const field = $('compose-recipient-field');
|
||||
sel.innerHTML = '';
|
||||
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() {
|
||||
@@ -247,31 +372,138 @@ async function sendCompose() {
|
||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||
|
||||
async function loadInbox() {
|
||||
const status = await api('api/status');
|
||||
$('inbox-count').textContent = status.inbox_count || 0;
|
||||
const list = $('inbox-list');
|
||||
if (!status.inbox_count) {
|
||||
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
||||
} else {
|
||||
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
|
||||
list.innerHTML = `
|
||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||
<div class="skeleton-row"><div class="skel"></div></div>
|
||||
`;
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
$('inbox-count').textContent = messages.length;
|
||||
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
||||
// Mirror count into the header status so the unread dot stays in sync
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||
|
||||
if (!messages.length) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="ph ph-tray icon"></i>
|
||||
<div class="label">inbox empty</div>
|
||||
<div class="hint">Messages from other tenants will show up here.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = messages.map(m => `
|
||||
<div class="inbox-item" data-id="${m.id}">
|
||||
<div class="content">
|
||||
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
|
||||
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
||||
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn) {
|
||||
// Inline confirmation: replace the trash button with yes/no.
|
||||
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||
// passing escaped HTML through attributes.
|
||||
const row = btn.closest('.inbox-item');
|
||||
if (!row) return;
|
||||
const origHtml = btn.outerHTML;
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'del-confirm';
|
||||
wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
|
||||
wrap.innerHTML = `
|
||||
<span style="color:var(--danger);">delete?</span>
|
||||
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
|
||||
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
|
||||
`;
|
||||
btn.replaceWith(wrap);
|
||||
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||
if (res?.ok) {
|
||||
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
toast(res?.error || 'delete failed', 'err');
|
||||
}
|
||||
});
|
||||
wrap.querySelector('.del-no').addEventListener('click', () => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = origHtml;
|
||||
wrap.replaceWith(temp.firstChild);
|
||||
});
|
||||
}
|
||||
window.deleteMessage = deleteMessage;
|
||||
|
||||
async function deleteAllMessages() {
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
if (!messages.length) return;
|
||||
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
|
||||
let ok = 0;
|
||||
for (const m of messages) {
|
||||
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
|
||||
if (r?.ok) ok++;
|
||||
}
|
||||
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
}
|
||||
window.deleteAllMessages = deleteAllMessages;
|
||||
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
function formatRelTime(sqliteUtc) {
|
||||
if (!sqliteUtc) return '';
|
||||
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(t.getTime())) return sqliteUtc;
|
||||
const diff = (Date.now() - t.getTime()) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||
if (diff < 86400) {
|
||||
const today = new Date();
|
||||
const sameDay = today.toDateString() === t.toDateString();
|
||||
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
|
||||
return Math.floor(diff / 3600) + ' h ago';
|
||||
}
|
||||
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
|
||||
return t.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Admin: users ──
|
||||
|
||||
async function loadUsers() {
|
||||
const list = $('users-list');
|
||||
list.innerHTML = `
|
||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||
<div class="skeleton-row"><div class="skel"></div></div>
|
||||
`;
|
||||
const res = await api('api/users');
|
||||
if (res.error) { toast(res.error, true); return; }
|
||||
if (res.error) { toast(res.error, 'err'); return; }
|
||||
const users = res.users || [];
|
||||
$('users-count').textContent = users.length;
|
||||
const list = $('users-list');
|
||||
if (!users.length) {
|
||||
list.innerHTML = '<div class="empty">no users yet</div>';
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="ph ph-users icon"></i>
|
||||
<div class="label">no users yet</div>
|
||||
<div class="hint">Create the first one with the form on the right.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = users.map(u => `
|
||||
<div class="user-row" data-id="${u.id}">
|
||||
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
|
||||
<i class="ph ph-user-circle user-icon"></i>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||
<div class="meta">created ${u.created_at}</div>
|
||||
@@ -315,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);
|
||||
@@ -382,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 } });
|
||||
@@ -399,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');
|
||||
|
||||
+52
-25
@@ -14,10 +14,12 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<header>
|
||||
<span class="dot"></span>
|
||||
<i class="ph ph-broadcast brand-icon"></i>
|
||||
<h1>hyprcrd relay</h1>
|
||||
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
|
||||
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
||||
<span class="spacer"></span>
|
||||
<div id="nav-buttons" class="hidden" style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
||||
<div id="nav-buttons" class="hidden">
|
||||
<span id="nav-user" class="user"></span>
|
||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||
@@ -30,6 +32,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<!-- ── Setup view (first install) ── -->
|
||||
<div id="view-setup" class="auth-form hidden">
|
||||
<i class="ph ph-broadcast auth-icon"></i>
|
||||
<h2>welcome to relay</h2>
|
||||
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
|
||||
<div class="field">
|
||||
@@ -43,7 +46,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<div class="field">
|
||||
<label>super-shared secret</label>
|
||||
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
|
||||
<p style="font-size:9px;color:var(--dim);margin-top:4px;line-height:1.4;">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
|
||||
<p class="field-hint">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
|
||||
<div id="setup-err" class="err"></div>
|
||||
@@ -51,6 +54,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<!-- ── Login view ── -->
|
||||
<div id="view-login" class="auth-form hidden">
|
||||
<i class="ph ph-key auth-icon"></i>
|
||||
<h2>login</h2>
|
||||
<div class="field">
|
||||
<label>username</label>
|
||||
@@ -70,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>
|
||||
@@ -88,13 +99,17 @@ 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>
|
||||
|
||||
<!-- ── Forced password change (first login) ── -->
|
||||
<div id="view-change-password" class="auth-form hidden">
|
||||
<i class="ph ph-key auth-icon"></i>
|
||||
<h2>change your password</h2>
|
||||
<p>This is your first login. Please choose a new password before you can use the relay.</p>
|
||||
<div class="field">
|
||||
@@ -117,35 +132,36 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<div id="view-account" class="hidden">
|
||||
<div class="section">
|
||||
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
|
||||
<p style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:14px;">
|
||||
<p class="field-hint" style="line-height:1.6;font-size:11px;color:var(--muted);">
|
||||
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
|
||||
All your devices share the same secret. Other tenants never see it. The relay stores it
|
||||
encrypted with the master key so it can route your messages.
|
||||
</p>
|
||||
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
|
||||
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
|
||||
<div id="account-no-secret" class="hidden notice warn">
|
||||
<i class="ph ph-warning"></i>
|
||||
<span>no secret set yet — your hyprcrd clients can't connect until you generate or set one below</span>
|
||||
</div>
|
||||
<div id="account-current" class="hidden" style="margin-bottom:14px;">
|
||||
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
|
||||
<div style="display:flex;gap:8px;align-items:stretch;">
|
||||
<input id="account-secret-display" type="password" readonly class="w-full" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
|
||||
<div id="account-current" class="hidden account-secret-block">
|
||||
<label class="field-label">current secret</label>
|
||||
<div class="account-secret-row">
|
||||
<input id="account-secret-display" type="password" readonly class="input">
|
||||
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
|
||||
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
|
||||
<div>
|
||||
<label class="field-label">set your own secret</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input id="account-secret-input" type="text" placeholder="min 8 chars" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
|
||||
<div class="account-secret-grid">
|
||||
<div class="field">
|
||||
<label>set your own secret</label>
|
||||
<div class="account-secret-row">
|
||||
<input id="account-secret-input" type="text" placeholder="min 8 chars" class="input">
|
||||
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;color:var(--dim);font-family:var(--pixel);font-size:9px;">— or —</div>
|
||||
<div class="account-divider">— or —</div>
|
||||
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
|
||||
</div>
|
||||
<div id="account-status" class="status" style="margin-top:12px;"></div>
|
||||
<div style="margin-top:24px;padding-top:14px;border-top:1px solid var(--border);">
|
||||
<div class="account-secret-actions">
|
||||
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,18 +173,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
|
||||
<div id="users-list"></div>
|
||||
<div class="create-user-form">
|
||||
<div>
|
||||
<label for="new-username" class="field-label">username</label>
|
||||
<div class="field">
|
||||
<label for="new-username">username</label>
|
||||
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="field-label">password</label>
|
||||
<div class="field">
|
||||
<label for="new-password">password</label>
|
||||
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
||||
</label>
|
||||
<p style="font-size:9px;color:var(--dim);line-height:1.4;margin-top:2px;">The new user sets their own client secret on first login.</p>
|
||||
<p class="field-hint">The new user sets their own client secret on first login.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
|
||||
<span id="create-user-status" class="status"></span>
|
||||
@@ -179,7 +195,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<!-- 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>
|
||||
</body>
|
||||
|
||||
+488
-195
@@ -5,51 +5,114 @@
|
||||
:root {
|
||||
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
|
||||
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
|
||||
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185;
|
||||
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185; --ok:#4ade80;
|
||||
--pixel:'Silkscreen', cursive;
|
||||
--mono:'JetBrains Mono', monospace;
|
||||
--radius:3px;
|
||||
--radius-lg:6px;
|
||||
--shadow-card:0 1px 2px rgba(0,0,0,0.3), 0 8px 24px -8px rgba(0,0,0,0.4);
|
||||
--shadow-popover:0 8px 24px rgba(0,0,0,0.4);
|
||||
--shadow-button:0 4px 12px -4px rgba(56,189,248,0.4);
|
||||
--focus-ring:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family:'JetBrains Mono', monospace;
|
||||
font-family:var(--mono);
|
||||
background:var(--bg); color:var(--text);
|
||||
min-height:100dvh; display:flex; flex-direction:column;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.pixel { font-family:var(--pixel); }
|
||||
.hidden { display:none !important; }
|
||||
.shrink { flex-shrink:0; }
|
||||
.spacer { flex:1; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Header
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
padding:12px 20px; border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; gap:12px; flex-shrink:0;
|
||||
background:var(--surface);
|
||||
position:relative;
|
||||
}
|
||||
header h1 { font-family:var(--pixel); font-size:14px; color:var(--accent); }
|
||||
header .brand-icon {
|
||||
color:var(--accent); font-size:18px; line-height:1;
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--accent) 35%, transparent));
|
||||
}
|
||||
header h1 {
|
||||
font-family:var(--pixel); font-size:14px; color:var(--accent);
|
||||
letter-spacing:0.05em;
|
||||
}
|
||||
.header-status {
|
||||
background:none; border:none; padding:4px 8px; cursor:pointer;
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
border-radius:var(--radius);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.header-status .ph { font-size:12px; }
|
||||
.header-status:hover {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.header-status.has-unread {
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
}
|
||||
.header-status.has-unread::after {
|
||||
content:''; position:absolute; top:2px; right:2px;
|
||||
width:6px; height:6px; border-radius:50%;
|
||||
background:var(--danger);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 60%, transparent);
|
||||
}
|
||||
.header-status:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
header .dot {
|
||||
width:8px; height:8px; border-radius:50%;
|
||||
background:var(--accent); animation:pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity:1; transform:scale(1); }
|
||||
50% { opacity:0.4; transform:scale(0.85); }
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
header .spacer { flex:1; }
|
||||
header .user {
|
||||
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
header .user .badge {
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; font-size:8px; border-radius:2px;
|
||||
padding:5px 10px; font-size:9px;
|
||||
border:1px solid var(--accent); border-radius:var(--radius);
|
||||
line-height:1; font-family:var(--pixel);
|
||||
}
|
||||
header .nav-btn {
|
||||
background:none; border:1px solid var(--border); color:var(--muted);
|
||||
cursor:pointer; padding:5px 10px;
|
||||
font-family:var(--pixel); font-size:9px; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:9px; border-radius:var(--radius);
|
||||
transition:all .15s;
|
||||
}
|
||||
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
|
||||
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
|
||||
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
|
||||
header .nav-btn:hover {
|
||||
color:var(--accent); border-color:var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
header .nav-btn:focus-visible {
|
||||
outline:none; box-shadow: var(--focus-ring);
|
||||
}
|
||||
header .nav-btn.active {
|
||||
background:var(--accent); color:var(--bg); border-color:var(--accent);
|
||||
box-shadow: 0 0 12px -2px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
}
|
||||
header .nav-btn.danger:hover {
|
||||
color:var(--danger); border-color:var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Layout shell
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Layouts ── */
|
||||
main {
|
||||
flex:1; display:flex; flex-direction:column;
|
||||
padding:20px; gap:16px; max-width:920px; width:100%;
|
||||
@@ -57,97 +120,183 @@ main {
|
||||
}
|
||||
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Form primitives — used by every view (auth, compose, account, admin)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.field { display:flex; flex-direction:column; gap:5px; width:100%; }
|
||||
.field-label,
|
||||
.field > label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
}
|
||||
.input,
|
||||
.field input[type="text"],
|
||||
.field input[type="password"],
|
||||
.field input[type="email"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
background:var(--bg); border:1px solid var(--border);
|
||||
color:var(--bright); padding:9px 12px; font-size:13px;
|
||||
font-family:var(--mono); border-radius:var(--radius);
|
||||
transition:border-color .15s, box-shadow .15s, background .15s;
|
||||
width:100%;
|
||||
}
|
||||
.input:focus,
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color:var(--accent); outline:none;
|
||||
box-shadow: var(--focus-ring);
|
||||
background: color-mix(in srgb, var(--bg) 96%, var(--accent) 4%);
|
||||
}
|
||||
.input::placeholder,
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder { color:var(--dim); }
|
||||
.field textarea { min-height:140px; resize:vertical; line-height:1.55; }
|
||||
.field-hint {
|
||||
font-size:9px; color:var(--dim); line-height:1.4;
|
||||
font-family:var(--mono);
|
||||
}
|
||||
|
||||
/* Inline label-on-checkbox style */
|
||||
label.checkbox {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
display:flex; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:pointer; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Buttons
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
padding:10px 18px; border:1px solid transparent; border-radius:var(--radius);
|
||||
font-family:var(--pixel); font-size:11px; cursor:pointer;
|
||||
transition:all .15s;
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
justify-content:center;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
|
||||
.btn-primary { background:var(--accent); color:var(--bg); border-color:var(--accent); }
|
||||
.btn-primary:hover {
|
||||
filter:brightness(1.12);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-button);
|
||||
}
|
||||
.btn-secondary {
|
||||
background:transparent; border-color:var(--border); color:var(--muted);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
color:var(--accent); border-color:var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-danger {
|
||||
background:transparent; border-color:var(--danger); color:var(--danger);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background:var(--danger); color:var(--bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Cards (compose, sections)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.compose-card,
|
||||
.section {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg); padding:18px;
|
||||
display:flex; flex-direction:column; gap:12px;
|
||||
}
|
||||
.compose-card h2,
|
||||
.section h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
}
|
||||
.section h2 .count {
|
||||
background:var(--bg); padding:3px 7px; border-radius:var(--radius);
|
||||
font-size:9px; color:var(--muted);
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Compose
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.compose-card .actions {
|
||||
display:flex; gap:8px; align-items:center;
|
||||
}
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
|
||||
/* Status text (used in compose, account, create-user) */
|
||||
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||
.status.ok { color:var(--ok); }
|
||||
.status.err { color:var(--danger); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Auth forms (setup, login, change-password)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.auth-form {
|
||||
display:flex; flex-direction:column; align-items:center;
|
||||
padding:48px 24px; gap:14px; max-width:380px; margin:0 auto;
|
||||
padding:40px 28px; gap:14px; max-width:400px; margin:48px auto 0;
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.auth-form .auth-icon {
|
||||
font-size:36px; color:var(--accent); margin-bottom:4px;
|
||||
filter: drop-shadow(0 0 16px color-mix(in srgb, var(--accent) 40%, transparent));
|
||||
}
|
||||
.auth-form h2 {
|
||||
font-family:var(--pixel); font-size:14px; color:var(--accent);
|
||||
margin-bottom:4px; letter-spacing:0.05em;
|
||||
}
|
||||
.auth-form p {
|
||||
font-size:11px; color:var(--muted); text-align:center; line-height:1.55;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.auth-form h2 { font-family:var(--pixel); font-size:13px; color:var(--accent); margin-bottom:4px; }
|
||||
.auth-form p { font-size:11px; color:var(--muted); text-align:center; line-height:1.5; }
|
||||
.auth-form .field { width:100%; }
|
||||
.auth-form .field label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; display:block; margin-bottom:4px;
|
||||
}
|
||||
.auth-form input {
|
||||
width:100%; background:var(--surface); border:1px solid var(--border);
|
||||
color:var(--bright); padding:9px 12px; font-size:13px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.auth-form input:focus { border-color:var(--accent); outline:none; }
|
||||
.auth-form .btn { width:100%; }
|
||||
.auth-form .err {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--danger);
|
||||
min-height:11px; text-align:center;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding:10px 18px; border:none; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:11px; cursor:pointer;
|
||||
transition:all .15s;
|
||||
/* Flash-highlight: a brief accent pulse on a card after the user
|
||||
tapped the header inbox link and we just scrolled it into view. */
|
||||
@keyframes flash-highlight {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 30%, transparent),
|
||||
0 0 24px 4px color-mix(in srgb, var(--accent) 50%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
.flash-highlight {
|
||||
animation: flash-highlight 1.6s ease-out;
|
||||
}
|
||||
.btn:active { opacity:.7; }
|
||||
.btn-primary { background:var(--accent); color:var(--bg); }
|
||||
.btn-primary:hover { filter:brightness(1.1); }
|
||||
.btn-secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||||
.btn-secondary:hover { color:var(--accent); border-color:var(--accent); }
|
||||
.btn-danger { background:transparent; border:1px solid var(--danger); color:var(--danger); }
|
||||
.btn-danger:hover { background:var(--danger); color:var(--bg); }
|
||||
|
||||
/* ── Compose view ── */
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.compose-card {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:4px; padding:16px;
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
}
|
||||
.compose-card h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.compose-card .field { display:flex; flex-direction:column; gap:4px; }
|
||||
.compose-card .field label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.compose-card input, .compose-card select, .compose-card textarea {
|
||||
background:var(--bg); border:1px solid var(--border); color:var(--bright);
|
||||
padding:8px 10px; font-size:13px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.compose-card input:focus, .compose-card select:focus, .compose-card textarea:focus {
|
||||
border-color:var(--accent); outline:none;
|
||||
}
|
||||
.compose-card textarea { min-height:140px; resize:vertical; line-height:1.55; }
|
||||
.compose-card .actions { display:flex; gap:8px; align-items:center; }
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
.compose-card .status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||
.compose-card .status.ok { color:var(--accent); }
|
||||
.compose-card .status.err { color:var(--danger); }
|
||||
|
||||
/* ── Inbox + admin lists ── */
|
||||
.section {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:4px; padding:16px;
|
||||
}
|
||||
.section h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase; margin-bottom:10px;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
}
|
||||
.section h2 .count {
|
||||
background:var(--bg); padding:2px 6px; border-radius:2px;
|
||||
font-size:9px; color:var(--muted);
|
||||
}
|
||||
.section .empty { font-size:11px; color:var(--dim); font-style:italic; padding:8px 0; }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Inbox + lists
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.inbox-item {
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
padding:10px 0; border-bottom:1px solid var(--border);
|
||||
padding:12px 10px; margin: 0 -10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
transition: background .12s;
|
||||
border-radius:var(--radius);
|
||||
}
|
||||
.inbox-item:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.inbox-item:last-child { border-bottom:none; }
|
||||
.inbox-item .meta {
|
||||
@@ -163,113 +312,287 @@ main {
|
||||
font-size:11px; color:var(--text); white-space:pre-wrap;
|
||||
word-break:break-word; max-height:120px; overflow:auto;
|
||||
}
|
||||
.inbox-item .from { font-family:var(--pixel); font-size:9px; color:var(--accent2); }
|
||||
.inbox-item .from {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--accent2);
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
.inbox-item .from .ph { font-size:11px; }
|
||||
.badge-new {
|
||||
display:inline-block;
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; border-radius:var(--radius);
|
||||
font-family:var(--pixel); font-size:8px;
|
||||
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:4px; font-size:14px; flex-shrink:0;
|
||||
padding:6px; font-size:14px; flex-shrink:0;
|
||||
border-radius:var(--radius);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.inbox-item .del:hover {
|
||||
color:var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
.inbox-item .del:hover { color:var(--danger); }
|
||||
|
||||
/* User row (admin) */
|
||||
.user-row {
|
||||
display:flex; align-items:center; gap:12px;
|
||||
padding:10px 0; border-bottom:1px solid var(--border);
|
||||
padding:12px 10px; margin: 0 -10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
border-radius:var(--radius);
|
||||
transition: background .12s;
|
||||
}
|
||||
.user-row:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.user-row:last-child { border-bottom:none; }
|
||||
.user-row .info { flex:1; }
|
||||
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
|
||||
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
|
||||
.user-row .name {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--bright);
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.user-row .meta { font-size:10px; color:var(--muted); margin-top:3px; }
|
||||
.user-row .badge {
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; border-radius:2px;
|
||||
padding:2px 7px; font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; border-radius:var(--radius);
|
||||
}
|
||||
.user-row .actions { display:flex; gap:6px; }
|
||||
.user-row .actions .btn { padding:4px 10px; font-size:9px; }
|
||||
.user-row .actions .btn { padding:5px 10px; font-size:9px; }
|
||||
|
||||
/* User-row icon */
|
||||
.user-row .user-icon { font-size:20px; color:var(--accent); flex-shrink:0; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Empty state — used by inbox, users list, etc.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; padding:36px 12px; gap:8px;
|
||||
color:var(--dim); text-align:center;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size:38px; color:var(--dim); opacity:0.6;
|
||||
}
|
||||
.empty-state .label {
|
||||
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
}
|
||||
.empty-state .hint {
|
||||
font-size:11px; color:var(--dim); line-height:1.5;
|
||||
max-width:280px;
|
||||
}
|
||||
.empty {
|
||||
font-size:11px; color:var(--dim); font-style:italic; padding:8px 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Loading skeleton (used by inbox / users list while fetching)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.skeleton-row {
|
||||
display:flex; align-items:center; gap:10px; padding:12px 10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.skeleton-row:last-child { border-bottom:none; }
|
||||
.skel {
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--raised) 60%, transparent) 0%,
|
||||
color-mix(in srgb, var(--raised) 90%, transparent) 50%,
|
||||
color-mix(in srgb, var(--raised) 60%, transparent) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.4s ease-in-out infinite;
|
||||
border-radius:2px; height:10px; width:60%;
|
||||
}
|
||||
.skel.short { width:30%; }
|
||||
.skel.tall { height:14px; }
|
||||
@keyframes skeleton {
|
||||
0% { background-position:200% 0; }
|
||||
100% { background-position:-200% 0; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Account view — secret display + input row
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.account-secret-row {
|
||||
display:flex; gap:8px; align-items:stretch;
|
||||
}
|
||||
.account-secret-row .input { flex:1; }
|
||||
.account-secret-block { margin-bottom:14px; }
|
||||
.account-divider {
|
||||
text-align:center; color:var(--dim);
|
||||
font-family:var(--pixel); font-size:9px;
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.account-secret-grid {
|
||||
display:grid; grid-template-columns:1fr; gap:14px;
|
||||
}
|
||||
.account-secret-actions {
|
||||
margin-top:24px; padding-top:14px; border-top:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Create user form (admin)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.create-user-form {
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
display:flex; flex-direction:column; gap:12px;
|
||||
padding-top:14px; border-top:1px solid var(--border); margin-top:14px;
|
||||
}
|
||||
.create-user-form .field-label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; display:block; margin-bottom:4px;
|
||||
}
|
||||
.create-user-form input[type="text"],
|
||||
.create-user-form input[type="password"] {
|
||||
width:100%;
|
||||
background:var(--bg); border:1px solid var(--border); color:var(--bright);
|
||||
padding:8px 11px; font-size:12px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.create-user-form input:focus { border-color:var(--accent); outline:none; }
|
||||
.create-user-form label.checkbox {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
display:flex; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
.create-user-form label.checkbox input[type="checkbox"] {
|
||||
accent-color:var(--accent); cursor:pointer;
|
||||
}
|
||||
.create-user-form .actions {
|
||||
display:flex; gap:8px; align-items:center;
|
||||
margin-top:4px;
|
||||
display:flex; gap:8px; align-items:center; margin-top:4px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Toast — single + stack support
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#toast-stack {
|
||||
position:fixed; top:20px; right:20px;
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:10px 18px; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:11px;
|
||||
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
|
||||
display:flex; flex-direction:column; gap:8px;
|
||||
z-index:200; pointer-events:none;
|
||||
}
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:var(--danger); }
|
||||
.toast {
|
||||
background:var(--surface); color:var(--bright);
|
||||
padding:10px 14px; border-radius:var(--radius-lg);
|
||||
font-family:var(--pixel); font-size:10px;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
border:1px solid var(--border);
|
||||
box-shadow: var(--shadow-popover);
|
||||
pointer-events:auto;
|
||||
transform: translateX(120%);
|
||||
transition: transform .25s cubic-bezier(0.16, 1, 0.3, 1), opacity .25s;
|
||||
opacity:0;
|
||||
min-width:180px; max-width:320px;
|
||||
}
|
||||
.toast.show { transform: translateX(0); opacity:1; }
|
||||
.toast.fade-out { opacity:0; transform: translateX(20%); }
|
||||
.toast .toast-icon { font-size:14px; flex-shrink:0; }
|
||||
.toast .toast-msg { line-height:1.3; word-break:break-word; }
|
||||
.toast.info { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
|
||||
.toast.info .toast-icon { color: var(--accent); }
|
||||
.toast.ok { border-color: color-mix(in srgb, var(--ok) 60%, var(--border)); }
|
||||
.toast.ok .toast-icon { color: var(--ok); }
|
||||
.toast.err { border-color: color-mix(in srgb, var(--danger) 60%, var(--border)); }
|
||||
.toast.err .toast-icon { color: var(--danger); }
|
||||
|
||||
.notice { padding:10px 14px; border-radius:3px; font-size:11px; margin-bottom:12px; }
|
||||
.notice.info { background:rgba(56,189,248,0.1); border:1px solid var(--accent); color:var(--accent); }
|
||||
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Notices
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.notice {
|
||||
padding:10px 14px; border-radius:var(--radius); font-size:11px;
|
||||
margin-bottom:12px;
|
||||
display:flex; align-items:flex-start; gap:8px;
|
||||
line-height:1.5;
|
||||
}
|
||||
.notice .ph { font-size:14px; flex-shrink:0; margin-top:1px; }
|
||||
.notice.info { background:rgba(56,189,248,0.08); border:1px solid color-mix(in srgb, var(--accent) 60%, var(--border)); color:var(--accent); }
|
||||
.notice.warn { background:rgba(251,113,133,0.08); border:1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); color:var(--danger); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Phosphor icon defaults
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Phosphor icons ── */
|
||||
.ph { display:inline-block; vertical-align:-2px; }
|
||||
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
||||
header h1 + .ph { font-size:12px; }
|
||||
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
|
||||
.btn .ph { font-size:12px; vertical-align:-2px; }
|
||||
.section h2 .ph, .compose-card h2 .ph { font-size:13px; vertical-align:-2px; }
|
||||
|
||||
/* ── Mobile: hide admin, identity, and the compose nav button ── */
|
||||
@media (max-width: 700px) {
|
||||
#nav-admin { display:none !important; }
|
||||
#view-admin { display:none !important; }
|
||||
#nav-user { display:none !important; }
|
||||
#nav-compose { display:none !important; }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Default nav layout (desktop)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#nav-buttons:not(.hidden) {
|
||||
display:flex; gap:6px; align-items:center;
|
||||
}
|
||||
#nav-burger {
|
||||
display:none;
|
||||
padding:5px 10px; font-size:11px;
|
||||
}
|
||||
|
||||
/* ── Desktop layout ── */
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Mobile: collapse nav into a burger menu
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#nav-user { display:none !important; }
|
||||
#nav-burger { display:inline-flex !important; align-items:center; }
|
||||
#nav-buttons:not(.hidden) { display:none; }
|
||||
#nav-buttons.menu-open {
|
||||
display:flex !important;
|
||||
flex-direction:column;
|
||||
align-items:stretch;
|
||||
position:absolute;
|
||||
top:100%;
|
||||
right:12px;
|
||||
margin-top:6px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding:6px;
|
||||
gap:4px;
|
||||
min-width:180px;
|
||||
z-index:50;
|
||||
}
|
||||
#nav-buttons.menu-open .nav-btn {
|
||||
text-align:left;
|
||||
width:100%;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
#view-admin .section {
|
||||
display:block !important;
|
||||
padding:14px 12px;
|
||||
}
|
||||
#view-admin .section h2 { margin-bottom:10px; }
|
||||
#view-admin .section .create-user-form { margin-top:14px; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Desktop layout (≥ 901px)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 901px) {
|
||||
|
||||
/* Subtle background gradient gives the page some depth */
|
||||
body {
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(56,189,248,0.04), transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.04), transparent 50%),
|
||||
radial-gradient(ellipse at top left, rgba(56,189,248,0.05), transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.05), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* Wider canvas, more breathing room */
|
||||
main {
|
||||
max-width: 1200px;
|
||||
padding: 32px 32px 48px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Header: bigger title, more padding */
|
||||
header {
|
||||
padding: 14px 32px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
header .brand-icon { font-size:20px; }
|
||||
header .nav-btn {
|
||||
padding: 7px 12px;
|
||||
}
|
||||
@@ -277,10 +600,10 @@ header h1 + .ph { font-size:12px; }
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Compose view: 2-column grid (compose | inbox) */
|
||||
/* Compose view: 2-column grid */
|
||||
#view-compose:not(.hidden) {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
||||
@@ -292,31 +615,28 @@ header h1 + .ph { font-size:12px; }
|
||||
.compose-card,
|
||||
.section {
|
||||
padding: 22px 24px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,0.3),
|
||||
0 8px 24px -8px rgba(0,0,0,0.4);
|
||||
box-shadow: var(--shadow-card);
|
||||
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
|
||||
}
|
||||
.compose-card h2,
|
||||
.section h2 {
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Big textarea for desktop typing */
|
||||
.compose-card textarea {
|
||||
.compose-card .field textarea {
|
||||
min-height: 320px;
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.compose-card input,
|
||||
.compose-card select {
|
||||
.compose-card .field input,
|
||||
.compose-card .field select {
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.compose-card .field label {
|
||||
.compose-card .field > label {
|
||||
font-size: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -330,11 +650,11 @@ header h1 + .ph { font-size:12px; }
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Inbox card: stick to top of viewport, hover lifts it */
|
||||
/* Inbox card sticks under the header */
|
||||
#inbox-section {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
#inbox-section:hover {
|
||||
box-shadow:
|
||||
@@ -343,7 +663,7 @@ header h1 + .ph { font-size:12px; }
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Admin view: 2-column grid (user list | create form) */
|
||||
/* Admin: 2-column grid */
|
||||
#view-admin:not(.hidden) .section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
@@ -364,44 +684,17 @@ header h1 + .ph { font-size:12px; }
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* User row polish: hover lift */
|
||||
.user-row {
|
||||
padding: 14px 12px;
|
||||
margin: 0 -12px;
|
||||
border-radius: 3px;
|
||||
transition: background .12s;
|
||||
}
|
||||
.user-row:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.user-row .name {
|
||||
font-size: 12px;
|
||||
}
|
||||
.user-row .name { font-size: 12px; }
|
||||
|
||||
/* Auth forms (setup/login) — vertically center on viewport */
|
||||
/* Auth-forms: full vertical centering, larger icon */
|
||||
.auth-form {
|
||||
margin-top: 64px;
|
||||
padding: 40px 32px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,0.3),
|
||||
0 16px 48px -16px rgba(0,0,0,0.5);
|
||||
max-width: 420px;
|
||||
margin-top: 88px;
|
||||
padding: 44px 36px;
|
||||
max-width: 440px;
|
||||
}
|
||||
.auth-form h2 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Buttons: subtle press */
|
||||
.btn { transition: all .12s; }
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -4px rgba(56,189,248,0.5);
|
||||
}
|
||||
.btn-secondary:hover { transform: translateY(-1px); }
|
||||
.btn-danger:hover { transform: translateY(-1px); }
|
||||
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
||||
.auth-form .auth-icon { font-size: 44px; }
|
||||
}
|
||||
Reference in new issue
Block a user