Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618a787b36 | ||
|
|
81d9ce0e87 | ||
|
|
bfd5420981 | ||
|
|
fa3a6c2501 | ||
|
|
1a889ea435 | ||
|
|
1d6499e5dd | ||
|
|
b7f450e41f | ||
|
|
6cd00f9660 | ||
|
|
4a668b7fc4 | ||
|
|
1966f8f4c0 | ||
|
|
26f95ba079 | ||
|
|
39e94b8015 | ||
|
|
69ad2c7e71 | ||
|
|
ae974314f9 | ||
|
|
3b7c7cf5ac | ||
|
|
ca47052768 | ||
|
|
002562b1d3 | ||
|
|
3cca14a3c6 | ||
|
|
7e2b130434 | ||
|
|
2169c1de1b | ||
|
|
b9bd294c90 | ||
|
|
6c0ca4373b | ||
|
|
9f85e484c4 | ||
|
|
b9345a410f | ||
|
|
00d14b3015 | ||
|
|
f465306c1d | ||
|
|
527305355c | ||
|
|
04b52ecc19 | ||
|
|
c5bf8b0407 | ||
|
|
d5f44370b6 |
No files matched your search
@@ -11,3 +11,7 @@
|
|||||||
electron/node_modules
|
electron/node_modules
|
||||||
data/hyprcrd.db-shm
|
data/hyprcrd.db-shm
|
||||||
data/hyprcrd.db-wal
|
data/hyprcrd.db-wal
|
||||||
|
|
||||||
|
/oumta-dashboard/
|
||||||
|
/oumta-dashboard-ui/
|
||||||
|
|
||||||
@@ -110,10 +110,23 @@ hyprcrd/
|
|||||||
│ │ └── vendor/ # marked.js, DOMPurify
|
│ │ └── vendor/ # marked.js, DOMPurify
|
||||||
│ ├── plugins/ # Optional, drop-in plugin folders
|
│ ├── plugins/ # Optional, drop-in plugin folders
|
||||||
│ │ ├── canvas/ # ASCII art canvas editor
|
│ │ ├── canvas/ # ASCII art canvas editor
|
||||||
|
│ │ ├── chat/ # Encrypted relay chat
|
||||||
|
│ │ ├── collab-buckets/ # Collaborative bucket sync
|
||||||
|
│ │ ├── email/ # Email overlay
|
||||||
|
│ │ ├── gitea/ # Gitea issue integration
|
||||||
|
│ │ ├── logseq-import/ # Logseq markdown importer
|
||||||
|
│ │ ├── slideshow/ # Presentation mode
|
||||||
│ │ └── tests/ # UI + state test runner
|
│ │ └── tests/ # UI + state test runner
|
||||||
│ └── uploads/ # User media (writable)
|
│ └── uploads/ # User media (writable)
|
||||||
├── relay/ # Relay service (mobile inbox + card sharing)
|
├── electron/ # Electron desktop app wrapper
|
||||||
│ ├── index.php # Single-file API + Mobile UI
|
│ ├── main.js # Spawns PHP, creates BrowserWindow
|
||||||
|
│ ├── router.php # PHP built-in server router
|
||||||
|
│ ├── fetch-php.sh # Downloads static PHP binaries
|
||||||
|
│ └── php-bin/ # Platform-specific PHP binaries
|
||||||
|
├── relay/ # Relay service (multi-tenant messaging + collab)
|
||||||
|
│ ├── index.php # Entry point + API dispatch
|
||||||
|
│ ├── inc/ # Backend modules (bootstrap, crypto, auth, routes)
|
||||||
|
│ ├── ui/ # Frontend (layout.php, style.css, app.js)
|
||||||
│ ├── .htaccess # URL rewriting + data protection
|
│ ├── .htaccess # URL rewriting + data protection
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── dist.sh # Build relay distribution ZIP
|
│ └── dist.sh # Build relay distribution ZIP
|
||||||
@@ -128,7 +141,7 @@ hyprcrd/
|
|||||||
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
|
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
|
||||||
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
|
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
|
||||||
- **Search** combining text and `#tag` filters (e.g. `#project meeting notes`)
|
- **Search** combining text and `#tag` filters (e.g. `#project meeting notes`)
|
||||||
- **Image gallery** with orphan detection and fullscreen viewer
|
- **Assets gallery** (images, audio, PDFs) with orphan detection and fullscreen viewer
|
||||||
- **Split view** showing tag-related and content-related cards
|
- **Split view** showing tag-related and content-related cards
|
||||||
- **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault
|
- **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault
|
||||||
- **Drag & drop** card reordering and grouping
|
- **Drag & drop** card reordering and grouping
|
||||||
@@ -141,7 +154,14 @@ hyprcrd/
|
|||||||
- **Notification banner** for upcoming events, birthdays, overdue tasks
|
- **Notification banner** for upcoming events, birthdays, overdue tasks
|
||||||
- **Board split view** with side-by-side preview
|
- **Board split view** with side-by-side preview
|
||||||
- **Superview** compact multi-column list with hover cards
|
- **Superview** compact multi-column list with hover cards
|
||||||
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete
|
- **Super-buckets** — nest buckets one level deep with a dropdown for children
|
||||||
|
- **Card groups** — drag-to-group, join multiple cards, dissolve
|
||||||
|
- **Context menu** — color, tags, copy text, mark done, join cards, make ticket (gitea)
|
||||||
|
- **Find & replace** — in-card (Ctrl+H) and across all cards (Ctrl+Shift+H via search)
|
||||||
|
- **Arrow key navigation** on the board with Enter to open
|
||||||
|
- **PDF upload** in the editor toolbar
|
||||||
|
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `?` shortcut reference, arrow keys, 1–9 bucket switch
|
||||||
|
- **Electron desktop app** — standalone AppImage/DMG/NSIS with bundled PHP, no server needed
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
@@ -150,7 +170,13 @@ hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plu
|
|||||||
### Bundled plugins
|
### Bundled plugins
|
||||||
|
|
||||||
- **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid.
|
- **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid.
|
||||||
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `?test` is in the URL or `Ctrl+Shift+T` is pressed, so production users pay no cost for it.
|
- **`chat/`** — Encrypted chat between relay tenants. Footer flyout with delivery + read receipts.
|
||||||
|
- **`collab-buckets/`** — Collaborative buckets shared between relay tenants with exclusive card locking and E2E encrypted sync.
|
||||||
|
- **`email/`** — Email overlay (toggled with `M` key).
|
||||||
|
- **`gitea/`** — Gitea integration: create issues from cards, link to repos.
|
||||||
|
- **`slideshow/`** — Slideshow presentation mode for cards with `slide-image` blocks.
|
||||||
|
- **`logseq-import/`** — Import Logseq markdown journals and pages.
|
||||||
|
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `Ctrl+Shift+Alt+T` is pressed, so production users pay no cost for it.
|
||||||
|
|
||||||
### Plugin layout
|
### Plugin layout
|
||||||
|
|
||||||
@@ -417,9 +443,8 @@ Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:808
|
|||||||
master key lives outside the DB in a chmod-600 file
|
master key lives outside the DB in a chmod-600 file
|
||||||
- **24-hour ephemeral**: messages and attachments auto-delete after a
|
- **24-hour ephemeral**: messages and attachments auto-delete after a
|
||||||
day. Cards extracted in hyprcrd are the long-term store
|
day. Cards extracted in hyprcrd are the long-term store
|
||||||
- **Future: end-to-end encrypted chat** between tenants is planned and
|
- **Encrypted chat**: tenant-to-tenant chat with delivery + read receipts
|
||||||
will require per-pair key exchange — see `relay/README.md` for the
|
(via chat plugin). Per-pair E2E key exchange is on the roadmap
|
||||||
roadmap
|
|
||||||
|
|
||||||
### Relay Security
|
### Relay Security
|
||||||
|
|
||||||
@@ -451,6 +476,26 @@ your next visit. You'll need to:
|
|||||||
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
|
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
|
||||||
client secrets need to be reissued.
|
client secrets need to be reissued.
|
||||||
|
|
||||||
|
## Electron Desktop App
|
||||||
|
|
||||||
|
hyprcrd can run as a standalone desktop app via Electron — no web server needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd electron
|
||||||
|
./fetch-php.sh # download static PHP binary (~20MB)
|
||||||
|
npm install
|
||||||
|
npm start # dev mode
|
||||||
|
npm run dist # build AppImage / DMG / NSIS
|
||||||
|
```
|
||||||
|
|
||||||
|
The app bundles a static PHP binary per platform and stores user data (database + uploads) outside the app bundle:
|
||||||
|
|
||||||
|
- **Linux**: `~/.config/hyprcrd/data/`
|
||||||
|
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
|
||||||
|
- **Windows**: `%APPDATA%/hyprcrd/data/`
|
||||||
|
|
||||||
|
See [`electron/README.md`](electron/README.md) for details.
|
||||||
|
|
||||||
## Rebuilding Tailwind CSS
|
## Rebuilding Tailwind CSS
|
||||||
|
|
||||||
After changing Tailwind utility classes in HTML or JS files:
|
After changing Tailwind utility classes in HTML or JS files:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bump version in config.php + electron/package.json
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
|
echo "Usage: ./bump.sh [--patch|--minor|--major]"
|
||||||
|
echo ""
|
||||||
|
echo " --patch bump patch version (default) 1.3.5 → 1.3.6"
|
||||||
|
echo " --minor bump minor version 1.3.6 → 1.4.0"
|
||||||
|
echo " --major bump major version 1.4.0 → 2.0.0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
|
|
||||||
|
case "${1:---patch}" in
|
||||||
|
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||||
|
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||||
|
*) PATCH=$((PATCH + 1)) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
|
||||||
|
# config.php
|
||||||
|
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
||||||
|
|
||||||
|
# electron/package.json
|
||||||
|
if [ -f electron/package.json ]; then
|
||||||
|
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${VERSION}\"/" electron/package.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
# website footer
|
||||||
|
if [ -f website/index.html ]; then
|
||||||
|
sed -i "s/v${CURRENT}/v${VERSION}/g" website/index.html
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${CURRENT} → ${VERSION}"
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.1.0');
|
define('APP_VERSION', '1.9.3');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"disabled": [
|
"disabled": [
|
||||||
"logseq-import"
|
"logseq-import",
|
||||||
|
"example-content"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Build hyprcrd distribution ZIP with version bump + minification
|
# Build hyprcrd distribution ZIP (no version bump — use ./bump.sh first)
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
echo "Usage: ./dist.sh [--patch|--minor|--major]"
|
echo "Usage: ./dist.sh"
|
||||||
echo ""
|
echo ""
|
||||||
echo " --patch bump patch version (default) 0.1.1 → 0.1.2"
|
echo "Builds Tailwind CSS, minifies JS/CSS, and creates"
|
||||||
echo " --minor bump minor version 0.1.2 → 0.2.0"
|
echo "hyprcrd-{version}.zip ready for deployment."
|
||||||
echo " --major bump major version 0.2.0 → 1.0.0"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php,"
|
echo "To bump the version first: ./bump.sh [--patch|--minor|--major]"
|
||||||
echo "and creates hyprcrd-{version}.zip ready for deployment."
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Read current version
|
VERSION=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||||
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
echo "Building v${VERSION}"
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
|
||||||
|
|
||||||
# Bump: --major, --minor, or patch (default)
|
|
||||||
case "${1:-patch}" in
|
|
||||||
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
|
||||||
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
|
||||||
*) PATCH=$((PATCH + 1)) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
|
||||||
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
|
||||||
echo "Version: ${CURRENT} → ${VERSION}"
|
|
||||||
|
|
||||||
DIST="hyprcrd-${VERSION}.zip"
|
DIST="hyprcrd-${VERSION}.zip"
|
||||||
STAGE="/tmp/hyprcrd-stage-$$"
|
STAGE="/tmp/hyprcrd-stage-$$"
|
||||||
|
|||||||
+7
-2
@@ -10,8 +10,13 @@ per platform so users don't need PHP installed.
|
|||||||
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
||||||
4. On quit, the PHP process is killed cleanly
|
4. On quit, the PHP process is killed cleanly
|
||||||
|
|
||||||
Data is stored in `data/hyprcrd.db` (SQLite), relative to the project root
|
User data (database + uploads) is stored **outside** the app bundle so it
|
||||||
(dev) or inside the app's `resources/` directory (packaged).
|
survives updates:
|
||||||
|
|
||||||
|
- **Dev**: `electron/appdata/`
|
||||||
|
- **Linux**: `~/.config/hyprcrd/data/`
|
||||||
|
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
|
||||||
|
- **Windows**: `%APPDATA%/hyprcrd/data/`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ async function startPhpServer() {
|
|||||||
console.log('[hyprcrd] port:', serverPort);
|
console.log('[hyprcrd] port:', serverPort);
|
||||||
|
|
||||||
phpProcess = spawn(phpBin, [
|
phpProcess = spawn(phpBin, [
|
||||||
|
'-d', 'upload_max_filesize=64M',
|
||||||
|
'-d', 'post_max_size=66M',
|
||||||
|
'-d', 'display_errors=0',
|
||||||
'-S', `127.0.0.1:${serverPort}`,
|
'-S', `127.0.0.1:${serverPort}`,
|
||||||
'-t', docRoot,
|
'-t', docRoot,
|
||||||
routerFile,
|
routerFile,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.1.0",
|
"version": "1.9.3",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ installation.
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Get static builds from https://github.com/crazywhalecc/static-php-cli/releases
|
Get static builds from https://dl.static-php.dev/static-php-cli/common/
|
||||||
(look for `php-8.3-*-cli` assets). Required extensions: pdo_sqlite, zip, session.
|
(look for `php-8.3.*-cli-*` files). Required extensions: pdo_sqlite, zip, session.
|
||||||
|
|
||||||
linux/php — x86_64 Linux ELF binary
|
linux/php — x86_64 Linux ELF binary
|
||||||
darwin/php — macOS universal binary (arm64 + x86_64)
|
darwin/php — macOS aarch64 binary
|
||||||
win32/php.exe — Windows PE binary
|
win32/php.exe — Windows PE binary (not available from static-php-cli; use windows.php.net)
|
||||||
|
|
||||||
## Quick fetch (Linux example)
|
## Quick fetch
|
||||||
|
|
||||||
curl -L -o linux/php \
|
Use the included script from the `electron/` directory:
|
||||||
"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`
|
./fetch-php.sh
|
||||||
|
|
||||||
|
This auto-detects the latest PHP 8.3 version and downloads Linux + macOS binaries.
|
||||||
+166
-21
@@ -1,7 +1,34 @@
|
|||||||
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
||||||
|
|
||||||
|
/* Tooltip system */
|
||||||
|
#tooltip {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--t-bright);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#tooltip.tooltip-help {
|
||||||
|
white-space: normal;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
max-width: 320px;
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
color: var(--t-muted);
|
||||||
|
border-left: 3px solid var(--t-accent);
|
||||||
|
}
|
||||||
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
|
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
|
||||||
.brand { font-family: 'Silkscreen', cursive !important; }
|
.brand { font-family: 'Silkscreen', cursive !important; }
|
||||||
body.no-scanlines::after { display: none !important; }
|
|
||||||
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
||||||
|
|
||||||
/* ── User-customizable design tokens ── */
|
/* ── User-customizable design tokens ── */
|
||||||
@@ -28,7 +55,7 @@ body.no-scanlines::after { display: none !important; }
|
|||||||
#main-header { box-shadow: var(--ui-shadow-header); }
|
#main-header { box-shadow: var(--ui-shadow-header); }
|
||||||
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
|
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
|
||||||
#footer-count { flex-shrink: 0; }
|
#footer-count { flex-shrink: 0; }
|
||||||
@media (min-width: 1250px) {
|
@media (min-width: 1450px) {
|
||||||
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
|
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
|
||||||
}
|
}
|
||||||
html { min-width: 1150px; }
|
html { min-width: 1150px; }
|
||||||
@@ -91,9 +118,12 @@ select[class*="border-t-accent"] {
|
|||||||
#tasks > div,
|
#tasks > div,
|
||||||
#calendar > div,
|
#calendar > div,
|
||||||
#gallery > div,
|
#gallery > div,
|
||||||
#dialog > div {
|
#dialog > div,
|
||||||
|
#detail > div,
|
||||||
|
#modal > div {
|
||||||
border-radius: var(--ui-radius-modal);
|
border-radius: var(--ui-radius-modal);
|
||||||
box-shadow: var(--ui-shadow-modal);
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
||||||
@@ -141,7 +171,6 @@ body.anim-slow .card-spinner {
|
|||||||
position: relative; padding: 14px; overflow: hidden;
|
position: relative; padding: 14px; overflow: hidden;
|
||||||
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
||||||
}
|
}
|
||||||
.theme-preview .tp-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px); }
|
|
||||||
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
||||||
.theme-preview .tp-card {
|
.theme-preview .tp-card {
|
||||||
flex: 1; min-width: 0;
|
flex: 1; min-width: 0;
|
||||||
@@ -196,7 +225,7 @@ body.anim-slow .card-spinner {
|
|||||||
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||||||
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
||||||
.design-row > select { width: 110px; }
|
.design-row > select { width: 110px; }
|
||||||
.design-row > .design-val { font-family: var(--t-font-pixel); font-size: 9px; color: var(--t-dim); width: 36px; text-align: right; flex-shrink: 0; }
|
.design-row > .design-val { font-size: 11px; color: var(--t-muted); width: 44px; text-align: right; flex-shrink: 0; }
|
||||||
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
||||||
.design-section-label:first-child { margin-top: 0; }
|
.design-section-label:first-child { margin-top: 0; }
|
||||||
#design-advanced > summary { list-style: none; }
|
#design-advanced > summary { list-style: none; }
|
||||||
@@ -268,16 +297,6 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
||||||
*::-webkit-scrollbar-corner { background: transparent; }
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
/* Scanlines */
|
|
||||||
body::after {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blinking cursor */
|
/* Blinking cursor */
|
||||||
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
@@ -463,9 +482,9 @@ body::after {
|
|||||||
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||||
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
||||||
.card-focused {
|
.card-focused {
|
||||||
outline: 2px solid var(--t-accent2) !important;
|
outline: 2px solid var(--t-accent) !important;
|
||||||
outline-offset: -2px;
|
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;
|
box-shadow: 0 0 0 1px var(--t-accent), 0 0 16px -4px color-mix(in srgb, var(--t-accent) 40%, transparent) !important;
|
||||||
}
|
}
|
||||||
.card-select-check {
|
.card-select-check {
|
||||||
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
||||||
@@ -821,6 +840,24 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
/* Super-bucket dropdown popover. Positioned via inline styles by
|
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||||
openSuperDropdown() in buckets.js. Lives on document.body (not
|
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||||
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
||||||
|
.super-caret {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.super-caret:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
.bucket-tab.active .super-caret:hover {
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
#bucket-super-popover {
|
#bucket-super-popover {
|
||||||
z-index: 220;
|
z-index: 220;
|
||||||
background: var(--t-surface);
|
background: var(--t-surface);
|
||||||
@@ -895,7 +932,7 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: var(--ui-radius-button); transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
||||||
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
||||||
|
|
||||||
/* Editor */
|
/* Editor */
|
||||||
@@ -1029,7 +1066,7 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
margin-left: -1.5em !important;
|
margin-left: -1.5em !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
@@ -1040,7 +1077,6 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
||||||
.todo-cb {
|
.todo-cb {
|
||||||
width: 18px; height: 18px; min-width: 18px;
|
width: 18px; height: 18px; min-width: 18px;
|
||||||
transform: translateY(-9px);
|
|
||||||
border: 2px solid var(--t-border); border-radius: 3px;
|
border: 2px solid var(--t-border); border-radius: 3px;
|
||||||
background: transparent; cursor: pointer;
|
background: transparent; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
@@ -1093,9 +1129,13 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
#board-preview-body.prose img,
|
#board-preview-body.prose img,
|
||||||
#board-preview-body .prose img {
|
#board-preview-body .prose img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: auto;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
#editor-preview .prose img:not([width]),
|
||||||
|
#board-preview-body.prose img:not([width]),
|
||||||
|
#board-preview-body .prose img:not([width]) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||||
@@ -1154,3 +1194,108 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
||||||
border-color: var(--t-accent);
|
border-color: var(--t-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remote-edit banner in the detail view.
|
||||||
|
Shown when a foreign sync replaced the card we're currently reading — the
|
||||||
|
user gets a manual Reload button instead of having the content change
|
||||||
|
under them mid-read. */
|
||||||
|
.detail-stale-banner {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: color-mix(in srgb, var(--t-accent2) 10%, transparent);
|
||||||
|
border-bottom: 1px solid var(--t-accent2);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
}
|
||||||
|
.detail-stale-banner i.ph-info { color: var(--t-accent2); flex-shrink: 0; font-size: 14px; }
|
||||||
|
.detail-stale-banner span { flex: 1; }
|
||||||
|
.detail-stale-banner button {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-accent2);
|
||||||
|
color: var(--t-accent2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
|
||||||
|
|
||||||
|
/* Sync + autosync combo: the pair reads as one button.
|
||||||
|
- At rest: Sync has no right border, autosync has no left border —
|
||||||
|
only the outer rectangle remains.
|
||||||
|
- Hover Sync: the right border comes back in accent so Sync gets a
|
||||||
|
complete outline around itself.
|
||||||
|
- Hover autosync: no accent border at all — instead a background
|
||||||
|
fill so the pair doesn't end up with a mismatched half-outline. */
|
||||||
|
.header-sync-group { align-items: stretch; }
|
||||||
|
.header-sync-group > button:first-child {
|
||||||
|
border-right: 0 !important;
|
||||||
|
}
|
||||||
|
.header-sync-group > button:first-child:hover {
|
||||||
|
border-right: 1px solid var(--t-accent) !important;
|
||||||
|
}
|
||||||
|
.header-sync-group > button + button {
|
||||||
|
border-left: 0 !important;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
#header-autosync:hover {
|
||||||
|
background: var(--t-accent);
|
||||||
|
border-color: var(--t-accent) !important;
|
||||||
|
color: var(--t-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autosync toggle button.
|
||||||
|
Border stays neutral in both states — the dot carries the status
|
||||||
|
signal (accent + pulse when on, grey when off). Hover styling is
|
||||||
|
handled by the bg-fill rule further down. */
|
||||||
|
#header-autosync {
|
||||||
|
border-color: var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-autosync-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px; height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-accent);
|
||||||
|
transition: background 0.15s, transform 0.2s;
|
||||||
|
}
|
||||||
|
#header-autosync[data-autosync="off"] .header-autosync-dot {
|
||||||
|
background: var(--t-dim);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* Hover: button fills with solid accent (matching the Sync button's
|
||||||
|
hover border color). Dot switches to the app background color so it
|
||||||
|
reads as a knockout against the fill. Pulsing keeps tracking real
|
||||||
|
status — when a sync fires during hover the ring still animates. */
|
||||||
|
#header-autosync:hover .header-autosync-dot {
|
||||||
|
background: var(--t-bg);
|
||||||
|
}
|
||||||
|
/* Pulse shadow is accent-colored too, which would be invisible against
|
||||||
|
the accent fill. Swap it to a bg-toned ring during hover so the
|
||||||
|
pulse stays visible. */
|
||||||
|
#header-autosync:hover .header-autosync-dot.pulsing {
|
||||||
|
animation: header-autosync-pulse-on-hover 0.9s ease-out 1;
|
||||||
|
}
|
||||||
|
@keyframes header-autosync-pulse-on-hover {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-bg) 75%, transparent); transform: scale(1); }
|
||||||
|
40% { transform: scale(1.4); }
|
||||||
|
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-bg) 0%, transparent); transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Burst pulse on each sync. JS adds `.pulsing` at the start of a sync
|
||||||
|
and removes it after the animation finishes. Tuned to be visible
|
||||||
|
against the button background — a generous ring that fades out. */
|
||||||
|
.header-autosync-dot.pulsing {
|
||||||
|
animation: header-autosync-pulse 0.9s ease-out 1;
|
||||||
|
}
|
||||||
|
@keyframes header-autosync-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-accent) 75%, transparent); transform: scale(1); }
|
||||||
|
40% { transform: scale(1.4); }
|
||||||
|
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-accent) 0%, transparent); transform: scale(1); }
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolderLine truncated
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||||
+174
-61
@@ -17,10 +17,20 @@ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Session ──
|
// ── Session ──
|
||||||
|
//
|
||||||
|
// 30-day persistence: single-user local-first app, no reason to log the
|
||||||
|
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
|
||||||
|
// server-side GC are set to the same duration so the session survives
|
||||||
|
// reboots AND page reloads, up to a month of inactivity.
|
||||||
|
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||||
|
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
|
'lifetime' => $sessionLifetime,
|
||||||
|
'path' => '/',
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
'samesite' => 'Strict',
|
'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
|
||||||
]);
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
@@ -250,12 +260,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||||
$pos = $input['position'] ?? 0;
|
$pos = $input['position'] ?? 0;
|
||||||
$body = $input['body'] ?? '';
|
$body = $input['body'] ?? '';
|
||||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
|
||||||
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
|
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
|
||||||
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
||||||
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
||||||
'position' => validInt($pos),
|
'position' => validInt($pos),
|
||||||
'tags' => extractTags($body),
|
'tags' => extractTags($body),
|
||||||
|
'due_date' => $input['due_date'] ?? null,
|
||||||
|
'scratchpad' => $input['scratchpad'] ?? null,
|
||||||
]);
|
]);
|
||||||
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
||||||
exit;
|
exit;
|
||||||
@@ -293,10 +305,32 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
||||||
|
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
|
||||||
|
$stmt->execute(['id' => $m[1]]);
|
||||||
|
echo json_encode($stmt->fetchAll()); exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
// Snapshot changed fields before update
|
||||||
|
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
|
||||||
|
$changedTracked = array_intersect($tracked, array_keys($input));
|
||||||
|
if ($changedTracked) {
|
||||||
|
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
|
||||||
|
if ($old) {
|
||||||
|
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
|
||||||
|
foreach ($changedTracked as $f) {
|
||||||
|
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
|
||||||
|
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
|
||||||
|
if ((string)$oldVal !== (string)$newVal) {
|
||||||
|
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
$fields = []; $params = ['id' => $m[1]];
|
$fields = []; $params = ['id' => $m[1]];
|
||||||
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
|
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
|
||||||
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
||||||
}
|
}
|
||||||
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
||||||
@@ -868,6 +902,32 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
$pulled++;
|
$pulled++;
|
||||||
|
|
||||||
|
// Fire a sync-event notification to the sender so their hyprcrd
|
||||||
|
// surfaces a toast. Best-effort — an older relay may not have
|
||||||
|
// /api/sync-events, and we don't want to fail the whole poll.
|
||||||
|
$senderName = $msg['sender_username'] ?? '';
|
||||||
|
if ($senderName && $senderName !== $relayUser) {
|
||||||
|
$eventBody = $relayUser . ' synced: ' . $title;
|
||||||
|
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
|
||||||
|
curl_setopt_array($bch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $relayUser,
|
||||||
|
'X-Relay-Key: ' . $relayKey,
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
$bRes = curl_exec($bch);
|
||||||
|
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($bch);
|
||||||
|
@error_log('[hyprcrd] sync-event POST → ' . $senderName
|
||||||
|
. ' status=' . $bStat
|
||||||
|
. ' body=' . substr((string)$bRes, 0, 120));
|
||||||
|
}
|
||||||
|
|
||||||
// Download attachments
|
// Download attachments
|
||||||
if ($relayUid) {
|
if ($relayUid) {
|
||||||
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
|
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
|
||||||
@@ -900,14 +960,45 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
if (strlen($raw) >= 17) {
|
if (strlen($raw) >= 17) {
|
||||||
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||||
if ($decrypted !== false) file_put_contents($dest, $decrypted);
|
if ($decrypted !== false) {
|
||||||
|
$fileBytes = base64_decode($decrypted);
|
||||||
|
if ($fileBytes !== false) $decrypted = $fileBytes;
|
||||||
|
file_put_contents($dest, $decrypted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
|
// Drain pending sync-event notifications so the JS caller can toast
|
||||||
|
// them. GET /api/sync-events on the relay auto-marks fetched events.
|
||||||
|
// Best-effort — older relays won't have the endpoint.
|
||||||
|
$syncEvents = [];
|
||||||
|
$syncDebug = ['requested' => true];
|
||||||
|
$sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
|
||||||
|
curl_setopt_array($sch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
$sResp = curl_exec($sch);
|
||||||
|
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($sch);
|
||||||
|
$syncDebug['status'] = $sStatus;
|
||||||
|
if ($sStatus === 200) {
|
||||||
|
$decoded = json_decode($sResp, true);
|
||||||
|
if (is_array($decoded) && isset($decoded['events'])) {
|
||||||
|
$syncEvents = $decoded['events'];
|
||||||
|
$syncDebug['count'] = count($syncEvents);
|
||||||
|
} else {
|
||||||
|
$syncDebug['parse_error'] = substr((string)$sResp, 0, 200);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$syncDebug['body'] = substr((string)$sResp, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents, 'sync_debug' => $syncDebug]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// List relay tenants (for the recipient picker)
|
// List relay tenants (for the recipient picker)
|
||||||
@@ -1130,7 +1221,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
// color theme
|
// color theme
|
||||||
theme: 'slate',
|
theme: 'slate',
|
||||||
// typography
|
// typography
|
||||||
scanlines: '1',
|
|
||||||
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
|
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
|
||||||
// geometry
|
// geometry
|
||||||
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
|
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
|
||||||
@@ -1144,7 +1234,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
window.builtinPresets = {
|
window.builtinPresets = {
|
||||||
Terminal: { ...window.designDefaults },
|
Terminal: { ...window.designDefaults },
|
||||||
Modern: { theme: 'paper',
|
Modern: { theme: 'paper',
|
||||||
scanlines: '0',
|
|
||||||
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
|
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
|
||||||
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
|
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
|
||||||
borderCard: 1, borderStyle: 'solid', barHeight: 3,
|
borderCard: 1, borderStyle: 'solid', barHeight: 3,
|
||||||
@@ -1182,7 +1271,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
root.setProperty('--ui-lift-card', d.liftCard + 'px');
|
root.setProperty('--ui-lift-card', d.liftCard + 'px');
|
||||||
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
|
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
|
||||||
// body classes
|
// body classes
|
||||||
document.body.classList.toggle('no-scanlines', String(d.scanlines) !== '1');
|
|
||||||
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
|
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
|
||||||
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
||||||
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
||||||
@@ -1194,8 +1282,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
// Migrate legacy individual keys (only if no JSON blob exists yet)
|
// Migrate legacy individual keys (only if no JSON blob exists yet)
|
||||||
if (!Object.keys(saved).length) {
|
if (!Object.keys(saved).length) {
|
||||||
const legacy = {};
|
const legacy = {};
|
||||||
const sl = localStorage.getItem('hyprcrd-scanlines');
|
|
||||||
if (sl !== null) legacy.scanlines = sl;
|
|
||||||
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
|
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
|
||||||
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
|
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
|
||||||
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
|
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
|
||||||
@@ -1225,11 +1311,16 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
|
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
|
||||||
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
|
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
|
||||||
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
|
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
|
||||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
|
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button id="header-sync" 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" title="sync now">
|
<div class="header-sync-group flex items-center">
|
||||||
|
<button id="header-sync" 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" data-tip="sync now">
|
||||||
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
|
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="header-autosync" class="flex items-center justify-center px-2 py-1.5 text-xs border border-t-border transition-colors" data-tip="auto-sync">
|
||||||
|
<span id="header-autosync-dot" class="header-autosync-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
<button id="calendar-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">
|
||||||
@@ -1250,25 +1341,25 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
|
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="ph ph-lightning text-t-accent text-sm"></i>
|
<i class="ph ph-lightning text-t-accent text-sm"></i>
|
||||||
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
|
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
|
||||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
|
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
|
||||||
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
|
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
|
||||||
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
|
<button id="board-split-toggle" class="toolbar-toggle" data-help="Split preview\nShows a side panel with card preview,\nrelated cards, and backlinks.">
|
||||||
<i class="ph ph-split-horizontal text-sm"></i>
|
<i class="ph ph-split-horizontal text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="superview-toggle" class="toolbar-toggle" title="compact list view">
|
<button id="superview-toggle" class="toolbar-toggle" data-help="Superview\nCompact multi-column list with\nhover cards. Great for overviews.">
|
||||||
<i class="ph ph-list text-sm"></i>
|
<i class="ph ph-list text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="hide-done" class="toolbar-toggle" title="toggle done">
|
<button id="hide-done" class="toolbar-toggle" data-tip="toggle done">
|
||||||
<i class="ph ph-check-circle text-sm"></i>
|
<i class="ph ph-check-circle text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="hide-termine" class="toolbar-toggle" title="toggle appointments">
|
<button id="hide-termine" class="toolbar-toggle" data-tip="toggle appointments">
|
||||||
<i class="ph ph-calendar-x text-sm"></i>
|
<i class="ph ph-calendar-x text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="hide-tagged" class="toolbar-toggle" title="toggle tagged cards">
|
<button id="hide-tagged" class="toolbar-toggle" data-tip="toggle tagged cards">
|
||||||
<i class="ph ph-hash text-sm"></i>
|
<i class="ph ph-hash text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button id="sort-toggle" class="toolbar-toggle">
|
<button id="sort-toggle" class="toolbar-toggle">
|
||||||
@@ -1621,7 +1712,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="theme-preview" class="theme-preview">
|
<div id="theme-preview" class="theme-preview">
|
||||||
<div class="tp-scan" id="theme-preview-scan"></div>
|
|
||||||
<div class="tp-row">
|
<div class="tp-row">
|
||||||
<div class="tp-card">
|
<div class="tp-card">
|
||||||
<div class="tp-card-bar"></div>
|
<div class="tp-card-bar"></div>
|
||||||
@@ -1650,7 +1740,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="design-section-label">presets</div>
|
<div class="design-section-label">presets</div>
|
||||||
<div class="design-row">
|
<div class="design-row">
|
||||||
<select id="design-preset-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer flex-1"></select>
|
<select id="design-preset-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer flex-1"></select>
|
||||||
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" title="Delete user preset"><i class="ph ph-trash text-xs"></i></button>
|
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" data-tip="delete user preset"><i class="ph ph-trash text-xs"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="design-row">
|
<div class="design-row">
|
||||||
<input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none">
|
<input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none">
|
||||||
@@ -1668,7 +1758,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="design-section-label">typography</div>
|
<div class="design-section-label">typography</div>
|
||||||
<div class="toggle-row"><span class="toggle-label">Scan lines</span><div class="toggle-switch" id="setting-scanlines"></div></div>
|
|
||||||
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
@@ -1693,7 +1782,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="design-section-label">effects</div>
|
<div class="design-section-label">effects</div>
|
||||||
<div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
<div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
||||||
<div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
<div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
||||||
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
|
||||||
<div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div>
|
<div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div>
|
||||||
<div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div>
|
<div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div>
|
||||||
<div class="design-row"><label>Animation</label><select id="setting-anim-speed" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div>
|
<div class="design-row"><label>Animation</label><select id="setting-anim-speed" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div>
|
||||||
@@ -1741,6 +1830,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="relay-test" class="flex items-center gap-1.5 px-3 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-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
<button id="relay-test" class="flex items-center gap-1.5 px-3 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-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||||
|
<div class="border-t border-t-border pt-4 mt-4">
|
||||||
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
|
||||||
|
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="text-xs text-t-muted" for="setting-sync-interval">Poll every</label>
|
||||||
|
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
|
||||||
|
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-panel-behavior" class="hidden space-y-5">
|
<div id="settings-panel-behavior" class="hidden space-y-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -1860,12 +1958,13 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div id="settings-panel-plugins" class="hidden space-y-4">
|
<div id="settings-panel-plugins" class="hidden space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
|
||||||
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.</p>
|
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggle multiple plugins and apply them together — the page reloads once when you click Apply.</p>
|
||||||
<div id="plugins-list" class="space-y-2"></div>
|
<div id="plugins-list" class="space-y-2"></div>
|
||||||
<div id="plugins-reload-notice" class="hidden mt-3 flex items-center gap-2 px-3 py-2 border border-t-accent bg-t-accent/10 text-xs text-t-accent">
|
<div id="plugins-apply-bar" class="hidden mt-3 flex items-center gap-3 px-3 py-2 border border-t-accent bg-t-accent/10">
|
||||||
<i class="ph ph-warning text-sm"></i>
|
<i class="ph ph-warning text-sm text-t-accent"></i>
|
||||||
<span class="flex-1 font-pixel text-[10px]">reload required to apply changes</span>
|
<span id="plugins-apply-count" class="flex-1 font-pixel text-[10px] text-t-accent"></span>
|
||||||
<button id="plugins-reload-btn" class="font-pixel text-[10px] underline hover:text-t-bright">Reload now</button>
|
<button id="plugins-discard-btn" class="font-pixel text-[10px] text-t-muted hover:text-t-danger transition-colors">discard</button>
|
||||||
|
<button id="plugins-apply-btn" class="font-pixel text-[10px] px-3 py-1 bg-t-accent text-t-bg hover:brightness-110 transition-all">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1881,6 +1980,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="border-t border-t-border pt-4">
|
<div class="border-t border-t-border pt-4">
|
||||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
|
||||||
|
<form id="settings-pw-form" onsubmit="return false;">
|
||||||
<div>
|
<div>
|
||||||
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
|
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
|
||||||
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
@@ -1894,9 +1994,10 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button id="settings-pw-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button>
|
<button id="settings-pw-save" type="button" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button>
|
||||||
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
|
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1935,7 +2036,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||||
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
|
||||||
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
|
||||||
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace">
|
<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" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open.">
|
||||||
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
@@ -2018,14 +2119,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="detail-delete" 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-trash text-sm"></i><span class="font-pixel text-[10px]">Del</span>
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Del</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="detail-done" 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="mark done">
|
<button id="detail-done" 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" data-tip="mark done">
|
||||||
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">Done</span>
|
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">Done</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="detail-zen" 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="zen mode">
|
<button id="detail-zen" 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" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
|
||||||
<i class="ph ph-corners-out text-sm"></i>
|
<i class="ph ph-corners-out text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="split-right-btn" 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="split view">
|
<button id="split-right-btn" 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" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
|
||||||
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
|
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="detail-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">
|
||||||
@@ -2064,14 +2165,16 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<!-- Calendar -->
|
<!-- Calendar -->
|
||||||
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
|
||||||
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
||||||
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
|
||||||
<h2 id="cal-title" class="font-pixel text-xs text-t-accent min-w-[120px] text-center"></h2>
|
|
||||||
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
|
|
||||||
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
||||||
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
||||||
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
||||||
|
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||||
|
<button id="cal-prev" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="previous"><i class="ph ph-caret-left text-base"></i></button>
|
||||||
|
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:280px;flex-shrink:0;"></h2>
|
||||||
|
<button id="cal-next" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="next"><i class="ph ph-caret-right text-base"></i></button>
|
||||||
|
</div>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
@@ -2169,11 +2272,11 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
|
||||||
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
|
<input type="text" id="inp-title" placeholder="> title..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
|
||||||
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="set due date">
|
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="set due date">
|
||||||
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
|
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="mark done">
|
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="mark done">
|
||||||
<i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">Done</span>
|
<i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">Done</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="hidden" id="inp-due-value" value="">
|
<input type="hidden" id="inp-due-value" value="">
|
||||||
@@ -2182,33 +2285,35 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<input type="hidden" id="inp-color" value="#38bdf8">
|
<input type="hidden" id="inp-color" value="#38bdf8">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
|
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
|
||||||
<button data-md="heading" title="Heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
|
<button data-md="heading" data-tip="heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
|
||||||
<button data-md="bold" title="Bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
|
<button data-md="bold" data-tip="bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
|
||||||
<button data-md="italic" title="Italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
|
<button data-md="italic" data-tip="italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
|
||||||
<button data-md="strikethrough" title="Strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
|
<button data-md="strikethrough" data-tip="strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button data-md="code" title="Code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
|
<button data-md="code" data-tip="code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
|
||||||
<button data-md="codeblock" title="Code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
|
<button data-md="codeblock" data-tip="code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button data-md="ul" title="List" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
|
<button data-md="ul" data-tip="list" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
|
||||||
<button data-md="tree" title="Tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
|
<button data-md="tree" data-tip="tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
|
||||||
<button data-md="ol" title="Numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
|
<button data-md="ol" data-tip="numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
|
||||||
<button data-md="quote" title="Quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
|
<button data-md="quote" data-tip="quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
|
||||||
<button data-md="todo" title="Todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
|
<button data-md="todo" data-tip="todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button data-md="link" title="Link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
|
<button data-md="link" data-tip="link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
|
||||||
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
<button data-md="cardlink" data-tip="card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
<button data-md="hr" data-tip="divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
<button data-md="extract" data-help="Extract\nMoves selected text into a new card\nand inserts a [[link]] in its place." 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>
|
<button id="md-upload-pdf" data-help="Upload PDF\nSelect a PDF file to upload.\nInserts a download link into the card." 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">
|
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
||||||
|
<button id="md-asset-picker" data-help="Insert from assets\nBrowse uploaded images, audio, and PDFs.\nClick a tile to insert into the editor." class="toolbar-btn" style="position:relative;"><i class="ph ph-images text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button 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>
|
<button id="md-find-replace" data-help="Find & replace\nSearch and replace text within this card.\nCtrl+H to open, Ctrl+F for find-only." class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
|
||||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||||
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
|
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
|
||||||
|
<button id="toggle-scratchpad" class="toolbar-btn flex items-center gap-1.5 px-2" data-help="Scratchpad\nPrivate notes about this card.\nSaved separately, not part of the content."><i class="ph ph-notepad text-sm"></i><span class="font-pixel text-[9px]">Scratch</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
||||||
@@ -2219,9 +2324,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
|
<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;">
|
<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>
|
<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-prev" class="toolbar-btn" data-tip="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-next" class="toolbar-btn" data-tip="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>
|
<button id="find-close" class="toolbar-btn" data-tip="close"><i class="ph ph-x text-xs"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="replace-row" class="flex items-center gap-2">
|
<div id="replace-row" class="flex items-center gap-2">
|
||||||
<i class="ph ph-swap text-xs text-t-dim"></i>
|
<i class="ph ph-swap text-xs text-t-dim"></i>
|
||||||
@@ -2232,11 +2337,19 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
<textarea id="editor-body" placeholder="> write markdown..." spellcheck="false" autocorrect="off" autocapitalize="off" class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||||
|
<div id="editor-scratchpad" class="bg-t-bg flex flex-col hidden" style="flex:1 1 0%;min-width:0;">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-t-border shrink-0">
|
||||||
|
<i class="ph ph-notepad text-xs text-t-dim"></i>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
|
||||||
|
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="scratchpad-body" placeholder="private notes..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+33
-9
@@ -13,11 +13,13 @@ import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './module
|
|||||||
import { openSearch, closeSearch } from './modules/search.js';
|
import { openSearch, closeSearch } from './modules/search.js';
|
||||||
import { showContextMenu } from './modules/context-menu.js';
|
import { showContextMenu } from './modules/context-menu.js';
|
||||||
import { undo, canUndo } from './modules/undo.js';
|
import { undo, canUndo } from './modules/undo.js';
|
||||||
|
import './modules/tooltip.js';
|
||||||
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
|
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
|
||||||
import { openGallery, closeGallery } from './modules/gallery.js';
|
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
|
import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
|
||||||
|
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
|
||||||
import { openPluginSettings } from './modules/plugin-settings.js';
|
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||||
|
|
||||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||||
@@ -40,7 +42,16 @@ window._getSelectedIds = getSelectedIds;
|
|||||||
window._clearSelection = clearSelection;
|
window._clearSelection = clearSelection;
|
||||||
window._refreshTasks = refreshTasksView;
|
window._refreshTasks = refreshTasksView;
|
||||||
window.openFindBar = openFindBar;
|
window.openFindBar = openFindBar;
|
||||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
window._reloadAll = async () => {
|
||||||
|
await loadBuckets();
|
||||||
|
state.cards = await api('api/cards') || [];
|
||||||
|
renderBoard();
|
||||||
|
refreshCalendar();
|
||||||
|
// Let plugins / modules react to a full card reload (detail view checks
|
||||||
|
// whether the currently viewed card is now stale; collab-buckets uses it
|
||||||
|
// to refresh its decorators).
|
||||||
|
window.hyprcrd?.hooks?.emit?.('cards.reloaded');
|
||||||
|
};
|
||||||
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
// 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).
|
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||||
window.state = state;
|
window.state = state;
|
||||||
@@ -143,10 +154,14 @@ importFile?.addEventListener('change', async e => {
|
|||||||
try {
|
try {
|
||||||
const url = mode ? 'api/import?merge=1' : 'api/import';
|
const url = mode ? 'api/import?merge=1' : 'api/import';
|
||||||
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
|
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
|
||||||
const data = JSON.parse(await res.text());
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
const data = JSON.parse(text);
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
||||||
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +224,8 @@ const { board: gBoard } = guards;
|
|||||||
|
|
||||||
// — Escape chain (highest priority first) —
|
// — 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: 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: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { if (document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
|
||||||
|
registerShortcut({ key: 'z', guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'detail', keys: '<kbd>Z</kbd>', label: 'toggle zen mode', handler: () => document.getElementById('detail-zen')?.click() });
|
||||||
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
|
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: 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: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
|
||||||
@@ -266,6 +282,8 @@ registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J
|
|||||||
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
|
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: '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: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
|
||||||
|
registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: '<kbd>W</kbd>', label: 'watch', handler: () => window._watchToggle?.() });
|
||||||
|
registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: '<kbd>I</kbd>', label: 'tim (time calculator)', handler: () => window._timToggle?.() });
|
||||||
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
|
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) => {
|
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]')];
|
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
|
||||||
@@ -310,17 +328,23 @@ registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelect
|
|||||||
const focused = document.querySelector('#board .card-focused');
|
const focused = document.querySelector('#board .card-focused');
|
||||||
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
||||||
}});
|
}});
|
||||||
// Delete selected cards
|
// Delete selected or focused card
|
||||||
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 () => {
|
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && (getSelectedIds().length > 0 || !!document.querySelector('#board .card-focused')), section: 'global', keys: '<kbd>⌫</kbd>', label: 'delete selected / focused', hidden: true, handler: async () => {
|
||||||
const ids = getSelectedIds();
|
let ids = getSelectedIds();
|
||||||
|
if (!ids.length) {
|
||||||
|
const focused = document.querySelector('#board .card-focused');
|
||||||
|
if (focused) ids = [focused.dataset.id];
|
||||||
|
}
|
||||||
|
if (!ids.length) return;
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
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' });
|
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
const { animateCardRemoval } = await import('./modules/board.js');
|
const { animateCardRemoval, clearCardFocus } = await import('./modules/board.js');
|
||||||
await animateCardRemoval(ids);
|
await animateCardRemoval(ids);
|
||||||
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
clearCardFocus();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
|||||||
@@ -584,8 +584,10 @@ export function renderBoard() {
|
|||||||
renderGroupBar(cards);
|
renderGroupBar(cards);
|
||||||
|
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
board.style.display = '';
|
board.style.display = 'flex';
|
||||||
board.innerHTML = `<div class="col-span-full text-center py-20">
|
board.style.alignItems = 'center';
|
||||||
|
board.style.justifyContent = 'center';
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
@@ -707,6 +709,8 @@ export function renderBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
board.style.display = '';
|
board.style.display = '';
|
||||||
|
board.style.alignItems = '';
|
||||||
|
board.style.justifyContent = '';
|
||||||
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
||||||
|
|
||||||
bindCardEvents();
|
bindCardEvents();
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ function renderBar() {
|
|||||||
// render (e.g. bucket reorder, plain renderBar() call).
|
// render (e.g. bucket reorder, plain renderBar() call).
|
||||||
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<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 === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" data-tip="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
||||||
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" data-tip="current"><i class="ph ph-lightning text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" data-tip="favorites"><i class="ph ph-star text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" data-tip="journal"><i class="ph ph-notebook text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" data-tip="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
||||||
${topLevel.map(b => {
|
${topLevel.map(b => {
|
||||||
const kids = childrenOf(b.id);
|
const kids = childrenOf(b.id);
|
||||||
const isSuper = kids.length > 0;
|
const isSuper = kids.length > 0;
|
||||||
@@ -309,7 +309,7 @@ function renderBar() {
|
|||||||
const shown = tileFor(b);
|
const shown = tileFor(b);
|
||||||
const c = shown.color || '#64748b';
|
const c = shown.color || '#64748b';
|
||||||
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
|
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
|
||||||
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`;
|
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><span class="super-caret" data-super-caret="${b.id}"><i class="ph ph-caret-down text-[10px]"></i></span></button>`;
|
||||||
}
|
}
|
||||||
const c = b.color || '#64748b';
|
const c = b.color || '#64748b';
|
||||||
const active = state.activeBucket == b.id;
|
const active = state.activeBucket == b.id;
|
||||||
@@ -400,7 +400,7 @@ function renderBar() {
|
|||||||
const c = m.color || '#64748b';
|
const c = m.color || '#64748b';
|
||||||
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
||||||
const isParent = m.id === parentId;
|
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>`;
|
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" data-tip="parent"></i>' : ''}</button>`;
|
||||||
};
|
};
|
||||||
pop.innerHTML = members.map(renderEntry).join('');
|
pop.innerHTML = members.map(renderEntry).join('');
|
||||||
// Append inside #bucket-bar (not document.body) so the collab
|
// Append inside #bucket-bar (not document.body) so the collab
|
||||||
@@ -462,12 +462,22 @@ function renderBar() {
|
|||||||
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
||||||
|
|
||||||
if (isSuperTile) {
|
if (isSuperTile) {
|
||||||
// Click opens the dropdown popover instead of activating the
|
// Caret arrow opens the dropdown; clicking the rest of the
|
||||||
// parent directly. The dropdown lists parent + every child.
|
// tab activates the last-shown child (or parent).
|
||||||
t.addEventListener('click', (e) => {
|
const caret = t.querySelector('[data-super-caret]');
|
||||||
|
if (caret) {
|
||||||
|
caret.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openSuperDropdown(t, parseInt(bid, 10));
|
openSuperDropdown(t, parseInt(bid, 10));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
t.addEventListener('click', () => {
|
||||||
|
const shownId = t.dataset.superShown;
|
||||||
|
setActiveBucket(shownId || bid);
|
||||||
|
state.activeGroup = null;
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
||||||
}
|
}
|
||||||
@@ -598,8 +608,13 @@ function renderBar() {
|
|||||||
}
|
}
|
||||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||||
await loadBuckets();
|
await loadBuckets();
|
||||||
|
if (created?.id) {
|
||||||
|
setActiveBucket(created.id);
|
||||||
|
state.activeGroup = null;
|
||||||
|
}
|
||||||
|
renderBar();
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-171
@@ -1,7 +1,13 @@
|
|||||||
// ── CalDAV Sync ──
|
// ── CalDAV Settings + Calendar Config ──
|
||||||
|
//
|
||||||
|
// Genuinely CalDAV-scoped concerns only:
|
||||||
|
// * Settings panel (connection URL / user / password, Test + Save)
|
||||||
|
// * Per-calendar color/icon configuration (persisted in localStorage)
|
||||||
|
//
|
||||||
|
// Sync orchestration (triggerSync, background poll, autosync toggle,
|
||||||
|
// sync-interval, relay settings, sync.tick hook) lives in sync.js.
|
||||||
|
|
||||||
import { $, esc, api, getCsrf } from './util.js';
|
import { $, esc, api, getCsrf } from './util.js';
|
||||||
import { state } from './state.js';
|
|
||||||
|
|
||||||
const CAL_ICONS = [
|
const CAL_ICONS = [
|
||||||
{ id: 'calendar-dots', label: 'calendar' },
|
{ id: 'calendar-dots', label: 'calendar' },
|
||||||
@@ -76,7 +82,7 @@ function renderCalList(calendars) {
|
|||||||
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 ml-1">
|
<div class="flex gap-1 ml-1">
|
||||||
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" data-tip="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -119,7 +125,6 @@ export async function loadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setCaldavTestStatus(text, kind = 'info') {
|
function setCaldavTestStatus(text, kind = 'info') {
|
||||||
if (!testStatus) return;
|
if (!testStatus) return;
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -170,52 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
|
|||||||
testStatus.style.color = 'var(--t-accent)';
|
testStatus.style.color = 'var(--t-accent)';
|
||||||
});
|
});
|
||||||
|
|
||||||
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
|
||||||
|
|
||||||
export async function triggerSync() {
|
|
||||||
const btn = $('cal-sync');
|
|
||||||
if (btn) btn.style.opacity = '0.5';
|
|
||||||
const headerIcon = $('header-sync-icon');
|
|
||||||
if (headerIcon) headerIcon.classList.add('card-spinner');
|
|
||||||
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
|
||||||
|
|
||||||
const res = await fetch('api/caldav/sync', {
|
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
||||||
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
|
||||||
|
|
||||||
if (btn) btn.style.opacity = '';
|
|
||||||
|
|
||||||
// Also poll relay
|
|
||||||
// (header spinner stays on until the relay poll completes too — see end)
|
|
||||||
const relayRes = await fetch('api/relay/poll', {
|
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
||||||
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const s = res.stats || {};
|
|
||||||
const rp = relayRes?.pulled || 0;
|
|
||||||
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
||||||
const allLog = res.log || [];
|
|
||||||
const hasErrors = allLog.some(l => l.status === 'error');
|
|
||||||
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
|
||||||
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
|
||||||
await window._reloadAll?.();
|
|
||||||
showLog(allLog);
|
|
||||||
showSyncNotification(total);
|
|
||||||
} else {
|
|
||||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
|
||||||
}
|
|
||||||
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLog(log) {
|
|
||||||
if (!logEl || !log?.length) return;
|
|
||||||
logEl.classList.remove('hidden');
|
|
||||||
logEl.innerHTML = log.map(l =>
|
|
||||||
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConnectionLog(lines) {
|
function showConnectionLog(lines) {
|
||||||
if (!logEl || !lines?.length) return;
|
if (!logEl || !lines?.length) return;
|
||||||
logEl.classList.remove('hidden');
|
logEl.classList.remove('hidden');
|
||||||
@@ -224,125 +183,4 @@ function showConnectionLog(lines) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Relay settings ──
|
|
||||||
|
|
||||||
function setRelayStatus(text, kind = 'info') {
|
|
||||||
const el = $('relay-status');
|
|
||||||
if (!el) return;
|
|
||||||
if (!text) {
|
|
||||||
el.classList.add('hidden');
|
|
||||||
el.textContent = '';
|
|
||||||
el.style.borderColor = '';
|
|
||||||
el.style.color = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
el.textContent = text;
|
|
||||||
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
|
||||||
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
|
||||||
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
$('relay-save')?.addEventListener('click', async () => {
|
|
||||||
const body = {
|
|
||||||
url: $('relay-url')?.value || '',
|
|
||||||
username: $('relay-username')?.value || '',
|
|
||||||
key: $('relay-key')?.value || '',
|
|
||||||
};
|
|
||||||
setRelayStatus('saving...');
|
|
||||||
await api('api/relay/settings', { method: 'POST', body });
|
|
||||||
setRelayStatus('saved', 'success');
|
|
||||||
setTimeout(() => setRelayStatus(''), 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
$('relay-test')?.addEventListener('click', async () => {
|
|
||||||
setRelayStatus('testing...');
|
|
||||||
const res = await fetch('api/relay/poll', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
||||||
body: JSON.stringify({ strict: true }),
|
|
||||||
}).then(r => r.json()).catch(() => null);
|
|
||||||
if (res?.ok) {
|
|
||||||
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
|
||||||
if (res.pulled) await window._reloadAll?.();
|
|
||||||
} else {
|
|
||||||
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
|
||||||
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function loadRelaySettings() {
|
|
||||||
const s = await api('api/relay/settings');
|
|
||||||
if (!s) return;
|
|
||||||
const urlInput = $('relay-url');
|
|
||||||
const userInput = $('relay-username');
|
|
||||||
const keyInput = $('relay-key');
|
|
||||||
if (urlInput) urlInput.value = s.url || '';
|
|
||||||
if (userInput) userInput.value = s.username || '';
|
|
||||||
if (keyInput) {
|
|
||||||
keyInput.value = s.key || '';
|
|
||||||
keyInput.placeholder = 'shared secret';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
|
||||||
$('header-sync')?.addEventListener('click', () => triggerSync());
|
|
||||||
|
|
||||||
// Auto-sync throttle
|
|
||||||
let lastAutoSync = 0;
|
|
||||||
export function autoSync() {
|
|
||||||
if (window._testRunning) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastAutoSync < 60000) return;
|
|
||||||
lastAutoSync = now;
|
|
||||||
triggerSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background sync every 2 minutes
|
|
||||||
function showSyncNotification(stats) {
|
|
||||||
const s = stats || {};
|
|
||||||
const parts = [];
|
|
||||||
if (s.pulled) parts.push(`${s.pulled} received`);
|
|
||||||
if (s.pushed) parts.push(`${s.pushed} sent`);
|
|
||||||
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
|
||||||
if (!parts.length) return;
|
|
||||||
|
|
||||||
const note = document.createElement('div');
|
|
||||||
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
|
||||||
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
|
||||||
document.body.appendChild(note);
|
|
||||||
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backgroundSync() {
|
|
||||||
if (state.bgSyncRunning) return;
|
|
||||||
state.bgSyncRunning = true;
|
|
||||||
try {
|
|
||||||
const headers = { 'X-CSRF-Token': getCsrf() };
|
|
||||||
const [res, relayRes] = await Promise.all([
|
|
||||||
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
||||||
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
||||||
]);
|
|
||||||
const s = res?.stats || {};
|
|
||||||
const rp = relayRes?.pulled || 0;
|
|
||||||
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
||||||
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
|
||||||
await window._reloadAll?.();
|
|
||||||
showSyncNotification(total);
|
|
||||||
}
|
|
||||||
} catch {} finally {
|
|
||||||
state.bgSyncRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background sync after app is ready
|
|
||||||
let bgSyncInterval = null;
|
|
||||||
export function startBackgroundSync() {
|
|
||||||
if (bgSyncInterval) return;
|
|
||||||
backgroundSync();
|
|
||||||
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
|
|
||||||
}
|
|
||||||
export function stopBackgroundSync() {
|
|
||||||
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { $, esc } from './util.js';
|
import { $, esc } from './util.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { stripMd, getTodos } from './markdown.js';
|
import { stripMd, getTodos } from './markdown.js';
|
||||||
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
import { autoSync } from './sync.js';
|
||||||
|
import { getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||||
import { renderCardHtml } from './board.js';
|
import { renderCardHtml } from './board.js';
|
||||||
|
|
||||||
const overlay = $('calendar');
|
const overlay = $('calendar');
|
||||||
@@ -88,20 +89,35 @@ function matchesRRule(card, targetDateStr) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMultiDayEnd(card) {
|
||||||
|
const m = (card.body || '').match(/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
function getCardsForDate(ds) {
|
function getCardsForDate(ds) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const targetYear = parseInt(ds.substring(0, 4));
|
const targetYear = parseInt(ds.substring(0, 4));
|
||||||
for (const c of state.cards) {
|
for (const c of state.cards) {
|
||||||
if (cardBaseDate(c) === ds) {
|
const base = cardBaseDate(c);
|
||||||
|
if (base === ds) {
|
||||||
results.push({ ...c, _recurring: false });
|
results.push({ ...c, _recurring: false });
|
||||||
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
|
} else if (base && base < ds) {
|
||||||
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
|
// Multi-day event: check if ds is within [base..end]
|
||||||
|
const end = getMultiDayEnd(c);
|
||||||
|
if (end && ds <= end) {
|
||||||
|
const startMs = Date.parse(base);
|
||||||
|
const curMs = Date.parse(ds);
|
||||||
|
const dayNum = Math.round((curMs - startMs) / 86400000) + 1;
|
||||||
|
const totalDays = Math.round((Date.parse(end) - startMs) / 86400000) + 1;
|
||||||
|
results.push({ ...c, _recurring: false, _multiDay: { day: dayNum, total: totalDays, isEnd: ds === end } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.caldav_rrule && matchesRRule(c, ds)) {
|
||||||
|
const baseYear = parseInt(base.substring(0, 4));
|
||||||
const rrule = parseRRule(c.caldav_rrule);
|
const rrule = parseRRule(c.caldav_rrule);
|
||||||
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
||||||
results.push({ ...c, _recurring: true, _age: age });
|
if (base !== ds) results.push({ ...c, _recurring: true, _age: age });
|
||||||
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
||||||
// Local birthday cards repeat yearly without RRULE
|
|
||||||
const base = cardBaseDate(c);
|
|
||||||
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
||||||
const age = targetYear - parseInt(base.substring(0, 4));
|
const age = targetYear - parseInt(base.substring(0, 4));
|
||||||
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
||||||
@@ -146,7 +162,25 @@ function clearCalHover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
title.textContent = `${MONTHS[state.calMonth]} ${state.calYear}`;
|
// Format: [MM-DD] Mon Y [KW-KW]
|
||||||
|
// MM-DD = today's month-day
|
||||||
|
// Mon Y = displayed month + year (abbreviated)
|
||||||
|
// KW-KW = ISO week range spanning the displayed month
|
||||||
|
const isoWeek = (y, m, d) => {
|
||||||
|
const dt = new Date(Date.UTC(y, m, d));
|
||||||
|
const dayNum = dt.getUTCDay() || 7;
|
||||||
|
dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
|
||||||
|
};
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const nowDate = new Date();
|
||||||
|
const todayMd = `${pad(nowDate.getMonth() + 1)}-${pad(nowDate.getDate())}`;
|
||||||
|
const monAbbr = MONTHS[state.calMonth].substring(0, 3);
|
||||||
|
const lastDay = new Date(state.calYear, state.calMonth + 1, 0).getDate();
|
||||||
|
const wStart = isoWeek(state.calYear, state.calMonth, 1);
|
||||||
|
const wEnd = isoWeek(state.calYear, state.calMonth, lastDay);
|
||||||
|
title.innerHTML = `<span style="color:var(--t-dim)">[${todayMd}]</span> ${monAbbr} ${state.calYear} <span style="color:var(--t-dim)">[${pad(wStart)}-${pad(wEnd)}]</span>`;
|
||||||
|
|
||||||
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
||||||
const startOffset = (firstDay + 6) % 7; // Mon=0
|
const startOffset = (firstDay + 6) % 7; // Mon=0
|
||||||
@@ -210,9 +244,11 @@ function render() {
|
|||||||
const calCfg = getCardCalConfig(c);
|
const calCfg = getCardCalConfig(c);
|
||||||
const itemColor = calCfg?.color || c.color;
|
const itemColor = calCfg?.color || c.color;
|
||||||
const itemIcon = calCfg?.icon || null;
|
const itemIcon = calCfg?.icon || null;
|
||||||
|
const md = c._multiDay;
|
||||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
||||||
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
||||||
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
if (md) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:${itemColor};flex-shrink:0;">${md.day}/${md.total}</span>`;
|
||||||
|
else if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
||||||
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||||
html += `<span class="truncate">${esc(name)}</span>`;
|
html += `<span class="truncate">${esc(name)}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -494,9 +530,14 @@ export function refreshCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openCalendar() {
|
export function openCalendar() {
|
||||||
|
// Only jump to today on first open — preserve the user's navigation
|
||||||
|
// when the calendar is re-opened (e.g. after saving a card in a
|
||||||
|
// different month).
|
||||||
|
if (state.calYear == null || state.calMonth == null) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
state.calYear = now.getFullYear();
|
state.calYear = now.getFullYear();
|
||||||
state.calMonth = now.getMonth();
|
state.calMonth = now.getMonth();
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
overlay.style.visibility = '';
|
overlay.style.visibility = '';
|
||||||
|
|||||||
@@ -195,36 +195,44 @@ export function showContextMenu(x, y, card) {
|
|||||||
</button>` : '');
|
</button>` : '');
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
const targetId = card.id;
|
||||||
const tag = btn.dataset.ctxTag;
|
const tag = btn.dataset.ctxTag;
|
||||||
const has = cardTags.includes(tag);
|
// Re-read the current card from state to avoid stale body
|
||||||
let newBody = card.body || '';
|
const fresh = state.cards.find(x => x.id == targetId) || card;
|
||||||
|
const freshTags = parseTags(fresh);
|
||||||
|
const has = freshTags.includes(tag);
|
||||||
|
let newBody = fresh.body || '';
|
||||||
if (has) {
|
if (has) {
|
||||||
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
||||||
cardTags.splice(cardTags.indexOf(tag), 1);
|
|
||||||
} else {
|
} else {
|
||||||
newBody = newBody.trimEnd() + ' #' + tag;
|
newBody = newBody.trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
|
||||||
}
|
}
|
||||||
card.body = newBody;
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
// Trust the server's response for the new tags string
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
|
||||||
|
// Update our local working copy
|
||||||
|
card.body = c?.body ?? newBody;
|
||||||
|
cardTags.length = 0;
|
||||||
|
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
|
||||||
renderCtxTags(tagInput.value);
|
renderCtxTags(tagInput.value);
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
const targetId = card.id;
|
||||||
const tag = btn.dataset.ctxTagNew;
|
const tag = btn.dataset.ctxTagNew;
|
||||||
const newBody = (card.body || '').trimEnd() + ' #' + tag;
|
const fresh = state.cards.find(x => x.id == targetId) || card;
|
||||||
card.body = newBody;
|
const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
allTags.push(tag);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
allTags.sort();
|
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
card.body = c?.body ?? newBody;
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
cardTags.length = 0;
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
|
||||||
|
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
|
||||||
tagInput.value = '';
|
tagInput.value = '';
|
||||||
renderCtxTags('');
|
renderCtxTags('');
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
|
|||||||
+100
-4
@@ -72,9 +72,104 @@ export function openDetail(id, options = {}) {
|
|||||||
|
|
||||||
renderSidebar(card);
|
renderSidebar(card);
|
||||||
showContent(card);
|
showContent(card);
|
||||||
|
dismissStaleBanner();
|
||||||
detail.classList.add('open');
|
detail.classList.add('open');
|
||||||
|
|
||||||
|
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remote-update detection ──
|
||||||
|
//
|
||||||
|
// When a foreign edit arrives for the card the user is currently viewing,
|
||||||
|
// the pull replaces state.cards but the open detail view keeps rendering
|
||||||
|
// the stale object it captured on open. Instead of force-re-rendering
|
||||||
|
// under the user's finger (jarring when they're mid-read), surface a
|
||||||
|
// banner with a manual Reload button.
|
||||||
|
|
||||||
|
let staleBannerEl = null;
|
||||||
|
|
||||||
|
function dismissStaleBanner() {
|
||||||
|
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStaleBanner(fresh) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
const host = detail.querySelector('#detail-controls')?.parentElement;
|
||||||
|
if (!host) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'detail-stale-banner';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<i class="ph ph-info"></i>
|
||||||
|
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
||||||
|
<button class="detail-stale-reload">
|
||||||
|
<i class="ph ph-arrows-clockwise text-xs"></i>
|
||||||
|
<span>Reload</span>
|
||||||
|
</button>
|
||||||
|
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
`;
|
||||||
|
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
||||||
|
staleBannerEl = bar;
|
||||||
|
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
||||||
|
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
||||||
|
if (current) {
|
||||||
|
state.viewingCard = current;
|
||||||
|
renderSidebar(current);
|
||||||
|
showContent(current);
|
||||||
|
// Treat the reload as an explicit "I've now seen the new version"
|
||||||
|
// — re-fire detail.opened so collab-buckets clears the red dot
|
||||||
|
// on the board card and persists mark-card-seen to the backend.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
|
||||||
|
}
|
||||||
|
dismissStaleBanner();
|
||||||
|
});
|
||||||
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
|
||||||
|
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
|
||||||
|
// local time — off by the tz offset (2h on CEST, hence "2h ago"
|
||||||
|
// for a 30-second-old update).
|
||||||
|
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
|
||||||
|
? iso
|
||||||
|
: iso.replace(' ', 'T') + 'Z';
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
if (!isFinite(t)) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After every full card reload, check whether the card we're viewing is
|
||||||
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||||
|
// detail view closed in the meantime.
|
||||||
|
function checkStaleViewingCard() {
|
||||||
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewingId = state.viewingCard.id;
|
||||||
|
const fresh = state.cards.find(c => c.id == viewingId);
|
||||||
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||||
|
const old = state.viewingCard;
|
||||||
|
// Compare updated_at OR any field that might have changed. The detail
|
||||||
|
// view captures viewingCard by reference on open, so a replaced
|
||||||
|
// state.cards array (via _reloadAll) gives us a different object — if
|
||||||
|
// any visible field differs, surface the banner.
|
||||||
|
const changed =
|
||||||
|
(fresh.updated_at || '') > (old.updated_at || '') ||
|
||||||
|
(fresh.title || '') !== (old.title || '') ||
|
||||||
|
(fresh.body || '') !== (old.body || '') ||
|
||||||
|
(fresh.color || '') !== (old.color || '') ||
|
||||||
|
(fresh.tags || '') !== (old.tags || '');
|
||||||
|
if (changed) showStaleBanner(fresh);
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard);
|
||||||
|
|
||||||
function clearMorphGhost() {
|
function clearMorphGhost() {
|
||||||
if (morphTimer) {
|
if (morphTimer) {
|
||||||
clearTimeout(morphTimer);
|
clearTimeout(morphTimer);
|
||||||
@@ -370,6 +465,7 @@ export function closeDetail(options = {}) {
|
|||||||
closeImageFullscreen();
|
closeImageFullscreen();
|
||||||
|
|
||||||
detail.classList.remove('open', 'zen');
|
detail.classList.remove('open', 'zen');
|
||||||
|
dismissStaleBanner();
|
||||||
removeZenHandles();
|
removeZenHandles();
|
||||||
document.documentElement.classList.remove('detail-zen-open');
|
document.documentElement.classList.remove('detail-zen-open');
|
||||||
document.body.classList.remove('detail-zen-open');
|
document.body.classList.remove('detail-zen-open');
|
||||||
@@ -433,7 +529,7 @@ function renderSidebar(card) {
|
|||||||
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
|
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
|
||||||
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
|
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
|
||||||
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
|
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
|
||||||
<button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" title="ungroup">
|
<button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" data-tip="ungroup">
|
||||||
<i class="ph ph-x text-[10px]"></i>
|
<i class="ph ph-x text-[10px]"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -554,7 +650,7 @@ function applyReadingResizeHandles(root) {
|
|||||||
const line = document.createElement('button');
|
const line = document.createElement('button');
|
||||||
line.type = 'button';
|
line.type = 'button';
|
||||||
line.className = 'detail-font-dragline';
|
line.className = 'detail-font-dragline';
|
||||||
line.title = 'drag to resize text';
|
line.dataset.tip = 'drag to resize text';
|
||||||
line.setAttribute('aria-label', 'resize text');
|
line.setAttribute('aria-label', 'resize text');
|
||||||
el.appendChild(line);
|
el.appendChild(line);
|
||||||
|
|
||||||
@@ -615,7 +711,7 @@ export function bindDetailImageFullscreen(root) {
|
|||||||
// Regular images: double-click, no gallery nav
|
// Regular images: double-click, no gallery nav
|
||||||
root.querySelectorAll('img').forEach(img => {
|
root.querySelectorAll('img').forEach(img => {
|
||||||
if (img.closest('.md-gallery')) return;
|
if (img.closest('.md-gallery')) return;
|
||||||
img.title = 'double-click to open fullscreen';
|
img.dataset.tip = 'double-click to fullscreen';
|
||||||
img.addEventListener('dblclick', e => {
|
img.addEventListener('dblclick', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -898,7 +994,7 @@ function loadSplitCard(id) {
|
|||||||
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
|
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
|
||||||
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
|
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" title="open as main card">
|
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" data-tip="open as main card">
|
||||||
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span>
|
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span>
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+224
-16
@@ -4,6 +4,7 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
|
|||||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const modal = $('modal');
|
const modal = $('modal');
|
||||||
const body = $('editor-body');
|
const body = $('editor-body');
|
||||||
@@ -13,6 +14,8 @@ const inpTitle = $('inp-title');
|
|||||||
const inpColor = $('inp-color');
|
const inpColor = $('inp-color');
|
||||||
const inpBucket = $('inp-bucket');
|
const inpBucket = $('inp-bucket');
|
||||||
const presets = $('color-presets');
|
const presets = $('color-presets');
|
||||||
|
const scratchpadPanel = $('editor-scratchpad');
|
||||||
|
const scratchpadBody = $('scratchpad-body');
|
||||||
const linkAc = $('link-ac');
|
const linkAc = $('link-ac');
|
||||||
const tagAc = $('tag-ac');
|
const tagAc = $('tag-ac');
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ function startCloseMorph() {
|
|||||||
|
|
||||||
CARD_COLORS.forEach(c => {
|
CARD_COLORS.forEach(c => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button'; btn.title = c.name;
|
btn.type = 'button'; btn.dataset.tip = c.name;
|
||||||
btn.dataset.color = c.hex;
|
btn.dataset.color = c.hex;
|
||||||
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
|
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
|
||||||
btn.style.background = c.hex;
|
btn.style.background = c.hex;
|
||||||
@@ -176,6 +179,45 @@ function selectColor(hex) {
|
|||||||
|
|
||||||
// ── Open / close ──
|
// ── Open / close ──
|
||||||
|
|
||||||
|
// ── Autobackup (draft recovery) ──
|
||||||
|
const DRAFT_KEY = 'hyprcrd-editor-draft';
|
||||||
|
let draftSaveTimer = null;
|
||||||
|
|
||||||
|
function draftKeyFor(cardId) {
|
||||||
|
return DRAFT_KEY + ':' + (cardId || 'new');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDraft(cardId) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftKeyFor(cardId));
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraftDebounced() {
|
||||||
|
clearTimeout(draftSaveTimer);
|
||||||
|
draftSaveTimer = setTimeout(() => {
|
||||||
|
const id = state.editingId || 'new';
|
||||||
|
const data = {
|
||||||
|
title: inpTitle.value,
|
||||||
|
body: body.value,
|
||||||
|
scratchpad: scratchpadBody?.value || '',
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
// Don't store empty drafts
|
||||||
|
if (!data.title.trim() && !data.body.trim() && !data.scratchpad.trim()) {
|
||||||
|
localStorage.removeItem(draftKeyFor(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(draftKeyFor(id), JSON.stringify(data)); } catch {}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraft(cardId) {
|
||||||
|
clearTimeout(draftSaveTimer);
|
||||||
|
try { localStorage.removeItem(draftKeyFor(cardId || 'new')); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export function openModal(card = null) {
|
export function openModal(card = null) {
|
||||||
state.editingId = card ? card.id : null;
|
state.editingId = card ? card.id : null;
|
||||||
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
||||||
@@ -189,25 +231,59 @@ export function openModal(card = null) {
|
|||||||
selectColor(card ? card.color : '#38bdf8');
|
selectColor(card ? card.color : '#38bdf8');
|
||||||
setDueDate(card?.due_date || '');
|
setDueDate(card?.due_date || '');
|
||||||
updateDoneToggle();
|
updateDoneToggle();
|
||||||
state.previewVisible = true;
|
if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
|
||||||
preview.classList.remove('hidden');
|
showSidePanel('preview');
|
||||||
editorDivider?.classList.remove('hidden');
|
|
||||||
$('toggle-preview').classList.add('text-t-accent');
|
|
||||||
applyEditorSplit();
|
|
||||||
updatePreview();
|
|
||||||
modal.classList.add('open');
|
modal.classList.add('open');
|
||||||
if (linesVisible) updateGutter();
|
if (linesVisible) updateGutter();
|
||||||
currentLine = -1;
|
currentLine = -1;
|
||||||
updateActiveLine();
|
updateActiveLine();
|
||||||
|
|
||||||
|
// Check for unsaved draft (newer than what we just loaded).
|
||||||
|
// Skipped during test runs — tests would block on the dialog.
|
||||||
|
const draft = loadDraft(card?.id);
|
||||||
|
if (draft && !window._testRunning) {
|
||||||
|
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
||||||
|
const draftIsNewer = draft.savedAt > cardModified;
|
||||||
|
const draftHasContent = draft.title || draft.body || draft.scratchpad;
|
||||||
|
const differsFromLoaded = draft.title !== inpTitle.value || draft.body !== body.value || draft.scratchpad !== (scratchpadBody?.value || '');
|
||||||
|
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
||||||
|
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
||||||
|
const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
|
||||||
|
const cardId = card?.id;
|
||||||
|
// Async confirm — fires after openModal returns
|
||||||
|
(async () => {
|
||||||
|
const restore = await showDialog({
|
||||||
|
title: 'unsaved draft',
|
||||||
|
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
||||||
|
okLabel: 'Restore',
|
||||||
|
cancelLabel: 'Discard',
|
||||||
|
});
|
||||||
|
if (!modal.classList.contains('open') || state.editingId != cardId) return;
|
||||||
|
if (restore) {
|
||||||
|
inpTitle.value = draft.title || '';
|
||||||
|
body.value = draft.body || '';
|
||||||
|
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
||||||
|
updatePreview();
|
||||||
|
} else {
|
||||||
|
clearDraft(cardId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
} else if (draft && window._testRunning) {
|
||||||
|
clearDraft(card?.id);
|
||||||
|
}
|
||||||
|
|
||||||
inpTitle.focus();
|
inpTitle.focus();
|
||||||
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
||||||
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
inpTitle.addEventListener('input', updatePreview);
|
inpTitle.addEventListener('input', () => { updatePreview(); saveDraftDebounced(); });
|
||||||
inpTitle.addEventListener('keydown', e => {
|
inpTitle.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
||||||
});
|
});
|
||||||
|
body.addEventListener('input', saveDraftDebounced);
|
||||||
|
scratchpadBody?.addEventListener('input', saveDraftDebounced);
|
||||||
|
|
||||||
// ── Line numbers ──
|
// ── Line numbers ──
|
||||||
|
|
||||||
@@ -363,6 +439,7 @@ export function closeModal({ animate = true } = {}) {
|
|||||||
hideTagAc();
|
hideTagAc();
|
||||||
hideSnippetAc();
|
hideSnippetAc();
|
||||||
closeFindBar();
|
closeFindBar();
|
||||||
|
closeAssetPicker();
|
||||||
modal.classList.remove('open');
|
modal.classList.remove('open');
|
||||||
state.editingId = null;
|
state.editingId = null;
|
||||||
state.returnToGalleryAfterEdit = false;
|
state.returnToGalleryAfterEdit = false;
|
||||||
@@ -395,13 +472,16 @@ function updatePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(close, { animate = true } = {}) {
|
async function doSave(close, { animate = true } = {}) {
|
||||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
|
const scratch = scratchpadBody?.value || null;
|
||||||
|
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch };
|
||||||
if (!data.title.trim()) return;
|
if (!data.title.trim()) return;
|
||||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||||
|
const wasEditingId = state.editingId;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
||||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
|
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
|
||||||
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||||
|
clearDraft(state.editingId);
|
||||||
} else {
|
} else {
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
@@ -418,6 +498,7 @@ async function doSave(close, { animate = true } = {}) {
|
|||||||
state._lastSavedId = created.id;
|
state._lastSavedId = created.id;
|
||||||
state.recentlyCreatedIds?.add(String(created.id));
|
state.recentlyCreatedIds?.add(String(created.id));
|
||||||
}
|
}
|
||||||
|
clearDraft(null); // 'new' draft
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
// Auto-sync if card has #termin or #geburtstag
|
// Auto-sync if card has #termin or #geburtstag
|
||||||
@@ -535,18 +616,40 @@ function applyEditorSplit() {
|
|||||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('toggle-preview')?.addEventListener('click', () => {
|
function showSidePanel(which) {
|
||||||
state.previewVisible = !state.previewVisible;
|
const showPreview = which === 'preview';
|
||||||
preview.classList.toggle('hidden', !state.previewVisible);
|
const showScratch = which === 'scratchpad';
|
||||||
editorDivider?.classList.toggle('hidden', !state.previewVisible);
|
|
||||||
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
|
state.previewVisible = showPreview;
|
||||||
if (state.previewVisible) {
|
preview.classList.toggle('hidden', !showPreview);
|
||||||
|
scratchpadPanel?.classList.toggle('hidden', !showScratch);
|
||||||
|
editorDivider?.classList.toggle('hidden', !showPreview && !showScratch);
|
||||||
|
$('toggle-preview')?.classList.toggle('text-t-accent', showPreview);
|
||||||
|
$('toggle-scratchpad')?.classList.toggle('text-t-accent', showScratch);
|
||||||
|
|
||||||
|
if (showPreview || showScratch) {
|
||||||
applyEditorSplit();
|
applyEditorSplit();
|
||||||
|
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
|
||||||
} else {
|
} else {
|
||||||
if (editorPane) editorPane.style.flex = '1 1 0%';
|
if (editorPane) editorPane.style.flex = '1 1 0%';
|
||||||
preview.style.flex = '';
|
preview.style.flex = '';
|
||||||
|
if (scratchpadPanel) scratchpadPanel.style.flex = '';
|
||||||
}
|
}
|
||||||
updatePreview();
|
if (showPreview) updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('toggle-preview')?.addEventListener('click', () => {
|
||||||
|
showSidePanel(state.previewVisible ? null : 'preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('toggle-scratchpad')?.addEventListener('click', () => {
|
||||||
|
const isOpen = !scratchpadPanel?.classList.contains('hidden');
|
||||||
|
showSidePanel(isOpen ? null : 'scratchpad');
|
||||||
|
if (!isOpen) scratchpadBody?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('scratchpad-clear')?.addEventListener('click', () => {
|
||||||
|
if (scratchpadBody) scratchpadBody.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (editorDivider) {
|
if (editorDivider) {
|
||||||
@@ -1184,9 +1287,114 @@ $('md-upload-pdf-file')?.addEventListener('change', e => {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Asset picker (insert from uploads) ──
|
||||||
|
|
||||||
|
const ASSET_IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
const ASSET_AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
||||||
|
const ASSET_PDF_EXT = /\.pdf$/i;
|
||||||
|
|
||||||
|
function assetType(name) {
|
||||||
|
if (ASSET_IMAGE_EXT.test(name)) return 'image';
|
||||||
|
if (ASSET_AUDIO_EXT.test(name)) return 'audio';
|
||||||
|
if (ASSET_PDF_EXT.test(name)) return 'pdf';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
let assetPickerEl = null;
|
||||||
|
|
||||||
|
function closeAssetPicker() {
|
||||||
|
if (assetPickerEl) { assetPickerEl.remove(); assetPickerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('md-asset-picker')?.addEventListener('click', async () => {
|
||||||
|
if (assetPickerEl) { closeAssetPicker(); return; }
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
try { files = await api('api/uploads'); } catch {}
|
||||||
|
if (!Array.isArray(files)) files = [];
|
||||||
|
|
||||||
|
const btn = $('md-asset-picker');
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.id = 'asset-picker-flyout';
|
||||||
|
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;width:340px;max-height:400px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:200;display:flex;flex-direction:column;overflow:hidden;`;
|
||||||
|
|
||||||
|
const searchHtml = `<div style="padding:8px;border-bottom:1px solid var(--t-border);">
|
||||||
|
<input id="asset-picker-search" type="text" placeholder="search assets..." autocomplete="off"
|
||||||
|
style="width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:5px 8px;font-size:11px;font-family:var(--t-font-content,'JetBrains Mono',monospace);border-radius:4px;outline:none;"
|
||||||
|
class="focus:border-t-accent">
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const gridId = 'asset-picker-grid';
|
||||||
|
picker.innerHTML = searchHtml + `<div id="${gridId}" style="flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:6px;"></div>`;
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
assetPickerEl = picker;
|
||||||
|
|
||||||
|
const grid = picker.querySelector(`#${gridId}`);
|
||||||
|
const searchInput = picker.querySelector('#asset-picker-search');
|
||||||
|
|
||||||
|
function renderTiles(filter) {
|
||||||
|
const q = (filter || '').toLowerCase();
|
||||||
|
const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q)) : files;
|
||||||
|
if (!filtered.length) {
|
||||||
|
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:24px 0;color:var(--t-dim);font-size:10px;font-style:italic;">${files.length ? 'no matches' : 'no assets'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = filtered.map(f => {
|
||||||
|
const type = assetType(f.name);
|
||||||
|
const label = f.name.replace(/\.[^.]+$/, '');
|
||||||
|
if (type === 'image') {
|
||||||
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="image" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);">
|
||||||
|
<img src="${esc(f.url)}" style="width:100%;height:100%;object-fit:cover;" loading="lazy">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const icon = type === 'audio' ? 'ph-music-note' : type === 'pdf' ? 'ph-file-pdf' : 'ph-file';
|
||||||
|
const iconColor = type === 'pdf' ? 'color:var(--t-danger)' : 'color:var(--t-dim)';
|
||||||
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="${type}" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px;">
|
||||||
|
<i class="ph ${icon}" style="font-size:20px;${iconColor}"></i>
|
||||||
|
<span style="font-size:7px;color:var(--t-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--t-font-pixel,'Silkscreen',monospace);">${esc(label)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
grid.querySelectorAll('.asset-pick-tile').forEach(tile => {
|
||||||
|
tile.addEventListener('click', () => {
|
||||||
|
const url = tile.dataset.url;
|
||||||
|
const name = tile.dataset.name;
|
||||||
|
const type = tile.dataset.type;
|
||||||
|
let md;
|
||||||
|
if (type === 'image') md = `![${name.replace(/\.[^.]+$/, '')}](${url})`;
|
||||||
|
else if (type === 'pdf') md = `[📄 ${name}](${url})`;
|
||||||
|
else if (type === 'audio') md = `<audio controls src="${url}"></audio>`;
|
||||||
|
else md = `[${name}](${url})`;
|
||||||
|
insertMd(md);
|
||||||
|
closeAssetPicker();
|
||||||
|
});
|
||||||
|
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--t-accent)'; });
|
||||||
|
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--t-border)'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTiles('');
|
||||||
|
setTimeout(() => searchInput.focus(), 0);
|
||||||
|
searchInput.addEventListener('input', () => renderTiles(searchInput.value));
|
||||||
|
searchInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAssetPicker(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClickOutside = e => {
|
||||||
|
if (!picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
||||||
|
closeAssetPicker();
|
||||||
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Image resize ──
|
// ── Image resize ──
|
||||||
|
|
||||||
document.addEventListener('mousedown', e => {
|
document.addEventListener('mousedown', e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
const img = e.target.closest('.prose img');
|
const img = e.target.closest('.prose img');
|
||||||
if (!img || img.closest('.md-gallery-item')) return;
|
if (!img || img.closest('.md-gallery-item')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ export async function openGallery() {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
|
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
|
||||||
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" data-tip="new card"><i class="ph ph-plus text-sm"></i></button>
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" data-tip="new slide"><i class="ph ph-image-square text-sm"></i></button>
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function renderMd(src) {
|
|||||||
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
||||||
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
||||||
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
|
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
|
||||||
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
|
return `<span class="card-link broken" data-tip="card not found">${esc(title)}</span>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = marked.parse(linked);
|
let html = marked.parse(linked);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function render() {
|
|||||||
<span>${esc(i.label)}</span>
|
<span>${esc(i.label)}</span>
|
||||||
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
|
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" title="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
|
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" data-tip="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
banner.querySelectorAll('[data-notif-id]').forEach(el => {
|
banner.querySelectorAll('[data-notif-id]').forEach(el => {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// ── Plugin Settings UI ──
|
// ── Plugin Settings UI ──
|
||||||
// Renders the list of installed plugins with on/off toggles in the
|
// Toggles collect pending changes client-side and commit together via
|
||||||
// settings overlay. Disabled plugins are skipped at PHP render time;
|
// the Apply button. Only reloads once all changes are committed.
|
||||||
// toggling requires a reload to take effect (page must re-fetch the
|
|
||||||
// new script/css/html tag set).
|
|
||||||
|
|
||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
|
|
||||||
let pluginsCache = null;
|
let pluginsCache = null;
|
||||||
let dirtyAfterToggle = false;
|
let pending = new Map(); // name -> desired enabled state
|
||||||
|
|
||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
const res = await fetch('api/plugins');
|
const res = await fetch('api/plugins');
|
||||||
@@ -23,63 +21,86 @@ function render(list) {
|
|||||||
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = list.map(p => `
|
container.innerHTML = list.map(p => {
|
||||||
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
const effectiveEnabled = pending.has(p.name) ? pending.get(p.name) : p.enabled;
|
||||||
|
const isPending = pending.has(p.name);
|
||||||
|
return `
|
||||||
|
<div class="flex items-start gap-3 px-3 py-2 border ${isPending ? 'border-t-accent' : 'border-t-border'} bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
||||||
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
||||||
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
||||||
|
${isPending ? `<span class="font-pixel text-[8px] px-1 py-0.5 text-t-accent border border-t-accent uppercase">${effectiveEnabled ? 'will enable' : 'will disable'}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||||
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}">
|
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${effectiveEnabled ? 'checked' : ''} data-name="${esc(p.name)}" data-original="${p.enabled ? '1' : '0'}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
||||||
input.addEventListener('change', onToggle);
|
input.addEventListener('change', onToggle);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateApplyBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
let reloadTimer = null;
|
function onToggle(e) {
|
||||||
|
|
||||||
async function onToggle(e) {
|
|
||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const name = input.dataset.name;
|
const name = input.dataset.name;
|
||||||
const enabled = input.checked;
|
const original = input.dataset.original === '1';
|
||||||
input.disabled = true;
|
const desired = input.checked;
|
||||||
try {
|
if (desired === original) {
|
||||||
const res = await api('api/plugins/toggle', {
|
pending.delete(name);
|
||||||
method: 'POST',
|
} else {
|
||||||
body: { name, enabled },
|
pending.set(name, desired);
|
||||||
});
|
|
||||||
if (res.error) throw new Error(res.error);
|
|
||||||
pluginsCache = res.plugins;
|
|
||||||
dirtyAfterToggle = true;
|
|
||||||
$('plugins-reload-notice')?.classList.remove('hidden');
|
|
||||||
// Auto-reload so the effect of the toggle is visible immediately.
|
|
||||||
// Debounced so rapid successive toggles only trigger one reload.
|
|
||||||
if (reloadTimer) clearTimeout(reloadTimer);
|
|
||||||
reloadTimer = setTimeout(() => location.reload(), 400);
|
|
||||||
} catch (err) {
|
|
||||||
// Revert UI on failure
|
|
||||||
input.checked = !enabled;
|
|
||||||
console.error('[plugins] toggle failed:', err);
|
|
||||||
} finally {
|
|
||||||
input.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
render(pluginsCache); // re-render to update badge and border
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateApplyBar() {
|
||||||
|
const bar = $('plugins-apply-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
if (pending.size === 0) {
|
||||||
|
bar.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.classList.remove('hidden');
|
||||||
|
const count = pending.size;
|
||||||
|
const label = $('plugins-apply-count');
|
||||||
|
if (label) label.textContent = `${count} pending change${count !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChanges() {
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
const btn = $('plugins-apply-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'applying...'; }
|
||||||
|
try {
|
||||||
|
for (const [name, enabled] of pending) {
|
||||||
|
const res = await api('api/plugins/toggle', { method: 'POST', body: { name, enabled } });
|
||||||
|
if (res.error) throw new Error(`${name}: ${res.error}`);
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
// Reload so the new plugin set takes effect
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Apply'; }
|
||||||
|
alert('Failed to apply: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardChanges() {
|
||||||
|
pending.clear();
|
||||||
|
render(pluginsCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPluginSettings() {
|
export async function openPluginSettings() {
|
||||||
const container = $('plugins-list');
|
const container = $('plugins-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
||||||
if (!dirtyAfterToggle) {
|
|
||||||
$('plugins-reload-notice')?.classList.add('hidden');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const list = await fetchPlugins();
|
const list = await fetchPlugins();
|
||||||
render(list);
|
render(list);
|
||||||
@@ -88,9 +109,5 @@ export async function openPluginSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire reload button once at module load
|
$('plugins-apply-btn')?.addEventListener('click', applyChanges);
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
$('plugins-discard-btn')?.addEventListener('click', discardChanges);
|
||||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
|
||||||
}, { once: true });
|
|
||||||
// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
|
|
||||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// ── Sync Orchestration ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Owns the single source of truth for "time to sync". Every scheduled
|
||||||
|
// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
|
||||||
|
// the chat plugin — hangs off the same interval via the `sync.tick` hook
|
||||||
|
// instead of running its own setInterval. That way:
|
||||||
|
//
|
||||||
|
// - The header Sync button triggers ALL syncs (emits `sync.tick`).
|
||||||
|
// - The Autosync toggle pauses ALL syncs (stops the single timer).
|
||||||
|
// - The configurable interval changes ALL cadences at once.
|
||||||
|
//
|
||||||
|
// This module also owns the autosync button UI, the sync-event toast
|
||||||
|
// feed, and the relay settings panel (which hosts the interval input).
|
||||||
|
//
|
||||||
|
// Hooks emitted:
|
||||||
|
// sync.tick — fire all pollers now
|
||||||
|
// sync.pulse — pulse the header dot (subset of tick)
|
||||||
|
// sync.interval.changed — restart timers with new cadence
|
||||||
|
// autosync.changed — user paused/resumed scheduled polling
|
||||||
|
//
|
||||||
|
// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
|
||||||
|
// in caldav.js. This file has no CalDAV icons/colors concerns.
|
||||||
|
|
||||||
|
import { $, api, getCsrf } from './util.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
const syncStatus = $('caldav-sync-status');
|
||||||
|
|
||||||
|
// ── Manual "Sync now" trigger ───────────────────────────────────
|
||||||
|
// Fired by the header Sync button, the CalDAV panel "sync now" button,
|
||||||
|
// and the `autoSync()` throttled helper. Always runs regardless of the
|
||||||
|
// autosync-paused setting — the user pressed a button, we honor that.
|
||||||
|
// Fans out to every plugin poller via `sync.tick`.
|
||||||
|
export async function triggerSync() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
const btn = $('cal-sync');
|
||||||
|
if (btn) btn.style.opacity = '0.5';
|
||||||
|
const headerIcon = $('header-sync-icon');
|
||||||
|
if (headerIcon) headerIcon.classList.add('card-spinner');
|
||||||
|
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||||
|
|
||||||
|
const res = await fetch('api/caldav/sync', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
||||||
|
|
||||||
|
if (btn) btn.style.opacity = '';
|
||||||
|
|
||||||
|
// Also poll relay for mail-style messages. Header spinner stays on
|
||||||
|
// until this completes too.
|
||||||
|
const relayRes = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
|
||||||
|
// Fan out to plugin pollers (collab-buckets, chat, …). They run
|
||||||
|
// concurrently with us — no await needed here.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const s = res.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
||||||
|
const allLog = res.log || [];
|
||||||
|
const hasErrors = allLog.some(l => l.status === 'error');
|
||||||
|
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
||||||
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showLog(allLog);
|
||||||
|
showSyncNotification(total);
|
||||||
|
} else {
|
||||||
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||||
|
}
|
||||||
|
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV log display inside the Settings → CalDAV panel. Lives here
|
||||||
|
// (not in caldav.js) because triggerSync writes to it.
|
||||||
|
function showLog(log) {
|
||||||
|
const logEl = $('caldav-log');
|
||||||
|
if (!logEl || !log?.length) return;
|
||||||
|
logEl.classList.remove('hidden');
|
||||||
|
logEl.innerHTML = log.map(l =>
|
||||||
|
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-sync throttle ──────────────────────────────────────────
|
||||||
|
// Used by the CalDAV module when the user saves a card with #termin or
|
||||||
|
// #geburtstag — we want to fire a sync immediately but not storm the
|
||||||
|
// server if they paste a big batch. 60s cooldown between auto-fires.
|
||||||
|
let lastAutoSync = 0;
|
||||||
|
export function autoSync() {
|
||||||
|
if (window._testRunning) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAutoSync < 60000) return;
|
||||||
|
lastAutoSync = now;
|
||||||
|
triggerSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync notifications ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function showSyncNotification(stats) {
|
||||||
|
const s = stats || {};
|
||||||
|
const parts = [];
|
||||||
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
||||||
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
||||||
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
||||||
|
if (!parts.length) return;
|
||||||
|
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One toast per incoming sync event: "alice synced: Card Title".
|
||||||
|
// Stacks downward so a burst of multiple doesn't overlay each other.
|
||||||
|
let syncEventToastOffset = 0;
|
||||||
|
function showSyncEventToast(ev) {
|
||||||
|
const body = String(ev?.body || '').trim();
|
||||||
|
if (!body) return;
|
||||||
|
const bottom = 110 + syncEventToastOffset * 48;
|
||||||
|
syncEventToastOffset++;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
note.remove();
|
||||||
|
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
|
||||||
|
}, 350);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainSyncEvents(relayRes) {
|
||||||
|
if (relayRes?.sync_debug) {
|
||||||
|
console.debug('[sync-events]', relayRes.sync_debug);
|
||||||
|
}
|
||||||
|
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
|
||||||
|
for (const ev of events) showSyncEventToast(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduled background pull ───────────────────────────────────
|
||||||
|
// Internal helper — the CalDAV + relay mail side of a tick. Plugin
|
||||||
|
// pollers run concurrently via the `sync.tick` hook.
|
||||||
|
async function backgroundPull() {
|
||||||
|
if (state.bgSyncRunning) return;
|
||||||
|
state.bgSyncRunning = true;
|
||||||
|
try {
|
||||||
|
const headers = { 'X-CSRF-Token': getCsrf() };
|
||||||
|
const [res, relayRes] = await Promise.all([
|
||||||
|
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
]);
|
||||||
|
const s = res?.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
||||||
|
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showSyncNotification(total);
|
||||||
|
}
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
} catch {} finally {
|
||||||
|
state.bgSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central tick: the one and only scheduled sync heartbeat. Fires the
|
||||||
|
// CalDAV+relay pull and emits `sync.tick` for every other poller.
|
||||||
|
function tick() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
backgroundPull();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configurable sync interval ────────────────────────────────
|
||||||
|
// One setting drives every scheduled poller. Changed via the Relay
|
||||||
|
// settings panel; emits `sync.interval.changed` so the central timer
|
||||||
|
// restarts on the new cadence without a page reload.
|
||||||
|
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
|
||||||
|
const SYNC_INTERVAL_DEFAULT = 15;
|
||||||
|
const SYNC_INTERVAL_MIN = 5;
|
||||||
|
const SYNC_INTERVAL_MAX = 3600;
|
||||||
|
export function getSyncIntervalSec() {
|
||||||
|
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
|
||||||
|
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
|
||||||
|
}
|
||||||
|
export function setSyncIntervalSec(sec) {
|
||||||
|
const n = parseInt(sec);
|
||||||
|
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
|
||||||
|
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autosync on/off toggle ────────────────────────────────────
|
||||||
|
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
|
||||||
|
export function isAutoSyncEnabled() {
|
||||||
|
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
|
||||||
|
}
|
||||||
|
export function setAutoSyncEnabled(on) {
|
||||||
|
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
|
||||||
|
if (on) startBackgroundSync();
|
||||||
|
else stopBackgroundSync();
|
||||||
|
updateAutosyncButton();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central tick timer ────────────────────────────────────────
|
||||||
|
let tickInterval = null;
|
||||||
|
export function startBackgroundSync() {
|
||||||
|
if (tickInterval) return;
|
||||||
|
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
|
||||||
|
tick();
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
export function stopBackgroundSync() {
|
||||||
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the central timer when the user changes the interval.
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Header autosync button ────────────────────────────────────
|
||||||
|
function updateAutosyncButton() {
|
||||||
|
const btn = $('header-autosync');
|
||||||
|
if (!btn) return;
|
||||||
|
const on = isAutoSyncEnabled();
|
||||||
|
btn.dataset.autosync = on ? 'on' : 'off';
|
||||||
|
btn.setAttribute('data-tip', on
|
||||||
|
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
|
||||||
|
: 'auto-sync: paused — click to resume');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse the dot once. Rip and re-add the class after a reflow so
|
||||||
|
// re-triggering mid-animation restarts cleanly.
|
||||||
|
export function pulseAutosyncDot() {
|
||||||
|
const dot = $('header-autosync-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.remove('pulsing');
|
||||||
|
void dot.offsetWidth;
|
||||||
|
dot.classList.add('pulsing');
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
|
||||||
|
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
|
||||||
|
updateAutosyncButton();
|
||||||
|
|
||||||
|
// ── Sync-adjacent button wiring ───────────────────────────────
|
||||||
|
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('header-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||||
|
|
||||||
|
// ── Relay settings panel ──────────────────────────────────────
|
||||||
|
// The relay panel lives in Settings → Relay. It holds the URL /
|
||||||
|
// username / shared-key inputs plus the shared sync-interval number
|
||||||
|
// input that drives every scheduled poller.
|
||||||
|
|
||||||
|
function setRelayStatus(text, kind = 'info') {
|
||||||
|
const el = $('relay-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.borderColor = '';
|
||||||
|
el.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.textContent = text;
|
||||||
|
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
||||||
|
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
||||||
|
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('relay-save')?.addEventListener('click', async () => {
|
||||||
|
const body = {
|
||||||
|
url: $('relay-url')?.value || '',
|
||||||
|
username: $('relay-username')?.value || '',
|
||||||
|
key: $('relay-key')?.value || '',
|
||||||
|
};
|
||||||
|
setRelayStatus('saving...');
|
||||||
|
await api('api/relay/settings', { method: 'POST', body });
|
||||||
|
setRelayStatus('saved', 'success');
|
||||||
|
setTimeout(() => setRelayStatus(''), 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('relay-test')?.addEventListener('click', async () => {
|
||||||
|
setRelayStatus('testing...');
|
||||||
|
const res = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||||
|
body: JSON.stringify({ strict: true }),
|
||||||
|
}).then(r => r.json()).catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
||||||
|
if (res.pulled) await window._reloadAll?.();
|
||||||
|
drainSyncEvents(res);
|
||||||
|
} else {
|
||||||
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
||||||
|
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadRelaySettings() {
|
||||||
|
const s = await api('api/relay/settings');
|
||||||
|
if (!s) return;
|
||||||
|
const urlInput = $('relay-url');
|
||||||
|
const userInput = $('relay-username');
|
||||||
|
const keyInput = $('relay-key');
|
||||||
|
if (urlInput) urlInput.value = s.url || '';
|
||||||
|
if (userInput) userInput.value = s.username || '';
|
||||||
|
if (keyInput) {
|
||||||
|
keyInput.value = s.key || '';
|
||||||
|
keyInput.placeholder = 'shared secret';
|
||||||
|
}
|
||||||
|
// Sync-interval slider lives in the Relay panel but drives every
|
||||||
|
// poller. Snap points span 5s..1h; live label updates on drag,
|
||||||
|
// persisted value commits on release.
|
||||||
|
const intervalInp = $('setting-sync-interval');
|
||||||
|
const intervalVal = $('setting-sync-interval-val');
|
||||||
|
if (intervalInp) {
|
||||||
|
const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
|
||||||
|
const fmt = (s) => {
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s === 3600) return '1h';
|
||||||
|
const m = Math.floor(s / 60), r = s % 60;
|
||||||
|
return r ? `${m}m ${r}s` : `${m}m`;
|
||||||
|
};
|
||||||
|
const idxFor = (sec) => {
|
||||||
|
let best = 0, bestDiff = Infinity;
|
||||||
|
for (let i = 0; i < STEPS.length; i++) {
|
||||||
|
const d = Math.abs(STEPS[i] - sec);
|
||||||
|
if (d < bestDiff) { bestDiff = d; best = i; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
intervalInp.max = String(STEPS.length - 1);
|
||||||
|
const sync = (idx) => {
|
||||||
|
const sec = STEPS[idx];
|
||||||
|
if (intervalVal) intervalVal.textContent = fmt(sec);
|
||||||
|
return sec;
|
||||||
|
};
|
||||||
|
intervalInp.value = String(idxFor(getSyncIntervalSec()));
|
||||||
|
sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.oninput = () => sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.onchange = () => {
|
||||||
|
const sec = sync(parseInt(intervalInp.value));
|
||||||
|
setSyncIntervalSec(sec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,8 +96,6 @@ function updatePreview() {
|
|||||||
set('--tp-bar-height', d.barHeight + 'px');
|
set('--tp-bar-height', d.barHeight + 'px');
|
||||||
set('--tp-pad-card', d.padCard + 'px');
|
set('--tp-pad-card', d.padCard + 'px');
|
||||||
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
||||||
const scan = $('theme-preview-scan');
|
|
||||||
if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillFontSelect(id, value) {
|
function fillFontSelect(id, value) {
|
||||||
@@ -129,8 +127,6 @@ function syncControlsFromPending() {
|
|||||||
const valEl = $(valId);
|
const valEl = $(valId);
|
||||||
if (valEl) valEl.textContent = v;
|
if (valEl) valEl.textContent = v;
|
||||||
}
|
}
|
||||||
const scanEl = $('setting-scanlines');
|
|
||||||
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitDesign() {
|
function commitDesign() {
|
||||||
@@ -167,16 +163,6 @@ function initDesignSettings() {
|
|||||||
if (designWired) return;
|
if (designWired) return;
|
||||||
designWired = true;
|
designWired = true;
|
||||||
|
|
||||||
// Scanlines toggle
|
|
||||||
const scanEl = $('setting-scanlines');
|
|
||||||
const scanRow = scanEl?.closest('.toggle-row');
|
|
||||||
if (scanRow) scanRow.onclick = () => {
|
|
||||||
const nowOn = !scanEl.classList.contains('on');
|
|
||||||
scanEl.classList.toggle('on', nowOn);
|
|
||||||
designPending.scanlines = nowOn ? '1' : '0';
|
|
||||||
commitDesign();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Selects
|
// Selects
|
||||||
for (const [id, key] of SELECT_FIELDS) {
|
for (const [id, key] of SELECT_FIELDS) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// ── Global tooltip system ──
|
||||||
|
// Replaces native title="" with styled tooltips. Supports:
|
||||||
|
// data-tip="short text" — single-line tooltip (replaces title)
|
||||||
|
// data-help="line1\nline2" — multi-line inline help (shown on hover)
|
||||||
|
|
||||||
|
const SHOW_DELAY = 400;
|
||||||
|
const HIDE_DELAY = 100;
|
||||||
|
const OFFSET = 8;
|
||||||
|
|
||||||
|
let tipEl = null;
|
||||||
|
let showTimer = null;
|
||||||
|
let hideTimer = null;
|
||||||
|
let currentTarget = null;
|
||||||
|
|
||||||
|
function create() {
|
||||||
|
if (tipEl) return;
|
||||||
|
tipEl = document.createElement('div');
|
||||||
|
tipEl.id = 'tooltip';
|
||||||
|
tipEl.setAttribute('role', 'tooltip');
|
||||||
|
document.body.appendChild(tipEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(target) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
if (currentTarget === target) return;
|
||||||
|
currentTarget = target;
|
||||||
|
create();
|
||||||
|
|
||||||
|
const tip = target.dataset.tip || '';
|
||||||
|
const help = target.dataset.help || '';
|
||||||
|
if (!tip && !help) { hide(); return; }
|
||||||
|
|
||||||
|
if (help) {
|
||||||
|
tipEl.classList.add('tooltip-help');
|
||||||
|
tipEl.innerHTML = help.replace(/\\n/g, '<br>');
|
||||||
|
} else {
|
||||||
|
tipEl.classList.remove('tooltip-help');
|
||||||
|
tipEl.textContent = tip;
|
||||||
|
}
|
||||||
|
|
||||||
|
tipEl.style.visibility = 'hidden';
|
||||||
|
tipEl.style.display = 'block';
|
||||||
|
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const tipRect = tipEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top, left;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
|
||||||
|
if (spaceBelow >= tipRect.height + OFFSET || spaceBelow >= spaceAbove) {
|
||||||
|
top = rect.bottom + OFFSET;
|
||||||
|
tipEl.classList.remove('tooltip-above');
|
||||||
|
} else {
|
||||||
|
top = rect.top - tipRect.height - OFFSET;
|
||||||
|
tipEl.classList.add('tooltip-above');
|
||||||
|
}
|
||||||
|
|
||||||
|
left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||||
|
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
|
||||||
|
top = Math.max(4, top);
|
||||||
|
|
||||||
|
tipEl.style.top = top + 'px';
|
||||||
|
tipEl.style.left = left + 'px';
|
||||||
|
tipEl.style.visibility = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
currentTarget = null;
|
||||||
|
if (tipEl) tipEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter(e) {
|
||||||
|
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
|
||||||
|
const target = el?.closest?.('[data-tip],[data-help]');
|
||||||
|
if (!target) return;
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
showTimer = setTimeout(() => show(target), SHOW_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave(e) {
|
||||||
|
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
|
||||||
|
const target = el?.closest?.('[data-tip],[data-help]');
|
||||||
|
if (!target) return;
|
||||||
|
clearTimeout(showTimer);
|
||||||
|
hideTimer = setTimeout(hide, HIDE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerenter', onEnter, true);
|
||||||
|
document.addEventListener('pointerleave', onLeave, true);
|
||||||
|
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
|
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
|
document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
|
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
|
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
|
||||||
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button data-tool="brush" class="toolbar-btn active" data-tip="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
|
<button data-tool="eraser" class="toolbar-btn" data-tip="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
|
||||||
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
|
<button data-tool="line" class="toolbar-btn" data-tip="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
|
||||||
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
|
<button data-tool="rect" class="toolbar-btn" data-tip="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
|
||||||
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
|
<button data-tool="circle" class="toolbar-btn" data-tip="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
|
||||||
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
|
<button data-tool="fill" class="toolbar-btn" data-tip="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
|
||||||
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
|
<button data-tool="text" class="toolbar-btn" data-tip="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||||
<span>Char:</span>
|
<span>Char:</span>
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
<span>H:</span>
|
<span>H:</span>
|
||||||
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||||
</label>
|
</label>
|
||||||
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
|
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" data-tip="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button>
|
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" data-tip="Clear"><i class="ph ph-trash text-sm"></i></button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,15 +142,58 @@
|
|||||||
|
|
||||||
/* ── Thread view ── */
|
/* ── Thread view ── */
|
||||||
|
|
||||||
#chat-thread-header {
|
/* Horizontal peer strip above the thread header. Lets the user hop
|
||||||
|
between conversations without bouncing back to the list. Scrollbar
|
||||||
|
hidden — wheel-over scrolls horizontally (see chat.js). */
|
||||||
|
.chat-thread-peers {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 5px;
|
||||||
gap: 8px;
|
padding: 6px 8px;
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--t-border);
|
border-bottom: 1px solid var(--t-border);
|
||||||
background: var(--t-bg);
|
background: var(--t-bg);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.chat-thread-peers::-webkit-scrollbar { display: none; }
|
||||||
|
.chat-thread-peers:empty { display: none; }
|
||||||
|
|
||||||
|
.chat-peer-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button, 3px);
|
||||||
|
background: var(--t-surface);
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-peer-tab:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.chat-peer-tab.active {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
.chat-peer-tab .chat-peer-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
|
||||||
|
|
||||||
.chat-thread-messages {
|
.chat-thread-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
<div id="chat-flyout" class="hidden">
|
<div id="chat-flyout" class="hidden">
|
||||||
<div id="chat-flyout-header">
|
<div id="chat-flyout-header">
|
||||||
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
||||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span>
|
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
|
||||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" title="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" title="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
<span class="flex-1"></span>
|
||||||
|
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
||||||
|
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
|
||||||
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,15 +23,11 @@
|
|||||||
|
|
||||||
<!-- Conversation thread view -->
|
<!-- Conversation thread view -->
|
||||||
<div id="chat-thread-view" class="chat-view hidden">
|
<div id="chat-thread-view" class="chat-view hidden">
|
||||||
<div id="chat-thread-header">
|
<div id="chat-thread-peers" class="chat-thread-peers"></div>
|
||||||
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
|
||||||
<i class="ph ph-user-circle text-t-accent text-sm"></i>
|
|
||||||
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
|
||||||
</div>
|
|
||||||
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
||||||
<form id="chat-thread-form">
|
<form id="chat-thread-form">
|
||||||
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
|
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
|
||||||
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" title="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
|
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" data-tip="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ function showListView() {
|
|||||||
$('chat-thread-view').classList.add('hidden');
|
$('chat-thread-view').classList.add('hidden');
|
||||||
$('chat-pick-view').classList.add('hidden');
|
$('chat-pick-view').classList.add('hidden');
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
|
$('chat-header-back')?.classList.add('hidden');
|
||||||
currentPeer = null;
|
currentPeer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +151,17 @@ function showThreadView(peer) {
|
|||||||
$('chat-thread-view').classList.remove('hidden');
|
$('chat-thread-view').classList.remove('hidden');
|
||||||
$('chat-pick-view').classList.add('hidden');
|
$('chat-pick-view').classList.add('hidden');
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
$('chat-thread-peer').textContent = peer;
|
$('chat-header-back')?.classList.remove('hidden');
|
||||||
currentPeer = peer;
|
currentPeer = peer;
|
||||||
|
// Re-render tabs so the active highlight follows the thread switch.
|
||||||
|
renderPeerTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPickView() {
|
function showPickView() {
|
||||||
$('chat-list-view').classList.add('hidden');
|
$('chat-list-view').classList.add('hidden');
|
||||||
$('chat-thread-view').classList.add('hidden');
|
$('chat-thread-view').classList.add('hidden');
|
||||||
$('chat-pick-view').classList.remove('hidden');
|
$('chat-pick-view').classList.remove('hidden');
|
||||||
|
$('chat-header-back')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Conversation list ─────────────────────────────────────────────
|
// ── Conversation list ─────────────────────────────────────────────
|
||||||
@@ -174,6 +178,7 @@ function renderConversations() {
|
|||||||
const list = $('chat-list');
|
const list = $('chat-list');
|
||||||
const empty = $('chat-empty');
|
const empty = $('chat-empty');
|
||||||
if (!list || !empty) return;
|
if (!list || !empty) return;
|
||||||
|
renderPeerTabs();
|
||||||
if (!conversations.length) {
|
if (!conversations.length) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
empty.classList.remove('hidden');
|
empty.classList.remove('hidden');
|
||||||
@@ -205,6 +210,56 @@ function renderConversations() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
|
||||||
|
// Horizontal row above the thread so the user can hop between
|
||||||
|
// conversations without going back to the list. Hidden scrollbar;
|
||||||
|
// vertical wheel delta is redirected to horizontal scroll.
|
||||||
|
|
||||||
|
function renderPeerTabs() {
|
||||||
|
const strip = $('chat-thread-peers');
|
||||||
|
if (!strip) return;
|
||||||
|
// Fold the current peer in even if no messages exist yet (fresh
|
||||||
|
// pick-view selection), so switching back to it via tab is always
|
||||||
|
// possible.
|
||||||
|
const peers = conversations.slice();
|
||||||
|
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
|
||||||
|
peers.unshift({ peer: currentPeer, unread: 0 });
|
||||||
|
}
|
||||||
|
if (!peers.length) { strip.innerHTML = ''; return; }
|
||||||
|
strip.innerHTML = peers.map(c => {
|
||||||
|
const active = c.peer === currentPeer ? ' active' : '';
|
||||||
|
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
|
||||||
|
? '<span class="chat-peer-dot"></span>' : '';
|
||||||
|
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
|
||||||
|
}).join('');
|
||||||
|
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const peer = tab.dataset.peer;
|
||||||
|
if (peer && peer !== currentPeer) openConversation(peer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Keep the active tab in view after a switch so the user can see
|
||||||
|
// what's selected when the list overflows.
|
||||||
|
const active = strip.querySelector('.chat-peer-tab.active');
|
||||||
|
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect vertical wheel scrolls to horizontal scroll on the peer
|
||||||
|
// strip. Attached once on init — preventDefault only when we actually
|
||||||
|
// consume the delta, so a trackpad user's native horizontal gesture
|
||||||
|
// still works.
|
||||||
|
function wirePeerStripWheel() {
|
||||||
|
const strip = $('chat-thread-peers');
|
||||||
|
if (!strip || strip.dataset.wheelWired) return;
|
||||||
|
strip.dataset.wheelWired = '1';
|
||||||
|
strip.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (strip.scrollWidth <= strip.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
strip.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Thread ────────────────────────────────────────────────────────
|
// ── Thread ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function openConversation(peer) {
|
async function openConversation(peer) {
|
||||||
@@ -380,9 +435,10 @@ function init() {
|
|||||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||||
$('chat-new-btn').addEventListener('click', openPickView);
|
$('chat-new-btn').addEventListener('click', openPickView);
|
||||||
$('chat-empty-start')?.addEventListener('click', openPickView);
|
$('chat-empty-start')?.addEventListener('click', openPickView);
|
||||||
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
||||||
$('chat-pick-back').addEventListener('click', showListView);
|
$('chat-pick-back').addEventListener('click', showListView);
|
||||||
$('chat-thread-form').addEventListener('submit', sendMessage);
|
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||||
|
wirePeerStripWheel();
|
||||||
|
|
||||||
// Esc closes flyout when focus is inside it
|
// Esc closes flyout when focus is inside it
|
||||||
$('chat-flyout').addEventListener('keydown', e => {
|
$('chat-flyout').addEventListener('keydown', e => {
|
||||||
@@ -410,6 +466,12 @@ function init() {
|
|||||||
restartPoll();
|
restartPoll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also refresh on the global sync heartbeat. Our own timer keeps
|
||||||
|
// the faster flyout-open cadence (UX-specific); tick guarantees
|
||||||
|
// that the header Sync button refreshes chat too, even when the
|
||||||
|
// flyout is closed and our own timer sits at 20s.
|
||||||
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -49,10 +49,20 @@
|
|||||||
bucket_uid TEXT NOT NULL,
|
bucket_uid TEXT NOT NULL,
|
||||||
last_push_at TEXT,
|
last_push_at TEXT,
|
||||||
last_remote_at TEXT,
|
last_remote_at TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
");
|
");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
|
||||||
|
// Per-card unread tracking (added 2026-04-14). Existing deployments need
|
||||||
|
// the column added explicitly.
|
||||||
|
$ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name');
|
||||||
|
if (!in_array('last_seen_at', $ccsCols)) {
|
||||||
|
$db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT");
|
||||||
|
// Backfill: everything existing gets seen=now so we don't flood the
|
||||||
|
// user with a board full of red dots after they deploy this.
|
||||||
|
$db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')");
|
||||||
|
}
|
||||||
|
|
||||||
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
|
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
|
||||||
// table. The poll's push phase drains it and sends each entry as a
|
// table. The poll's push phase drains it and sends each entry as a
|
||||||
@@ -411,9 +421,16 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$stats['moved']++;
|
$stats['moved']++;
|
||||||
|
|
||||||
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
|
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
|
||||||
// Card is gone or moved out of collab entirely — forget about it
|
// Card is gone or moved out of collab entirely — forget about it.
|
||||||
|
// Critical: also clear the card's relay_uid so the incoming
|
||||||
|
// tombstone (echoed back from the relay on next pull) doesn't
|
||||||
|
// find this card and delete it locally too.
|
||||||
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
|
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
|
||||||
->execute(['id' => $mv['card_id']]);
|
->execute(['id' => $mv['card_id']]);
|
||||||
|
if (!$mv['card_missing']) {
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = NULL WHERE id = :id')
|
||||||
|
->execute(['id' => $mv['card_id']]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Card moved to a different collab bucket. Rewrite state so the
|
// Card moved to a different collab bucket. Rewrite state so the
|
||||||
// push phase below picks it up under the new bucket_uid and also
|
// push phase below picks it up under the new bucket_uid and also
|
||||||
@@ -436,17 +453,19 @@ function collabSyncCards(PDO $db): array {
|
|||||||
FROM cards c
|
FROM cards c
|
||||||
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
||||||
LEFT JOIN collab_card_state cs ON cs.card_id = c.id
|
LEFT JOIN collab_card_state cs ON cs.card_id = c.id
|
||||||
WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at
|
WHERE (cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at)
|
||||||
|
AND c.updated_at < datetime('now', '-2 seconds')
|
||||||
")->fetchAll();
|
")->fetchAll();
|
||||||
|
|
||||||
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
||||||
$stateUpsert = $db->prepare("
|
$stateUpsert = $db->prepare("
|
||||||
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
|
||||||
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
|
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2)
|
||||||
ON CONFLICT(card_id) DO UPDATE SET
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
relay_uid = excluded.relay_uid,
|
relay_uid = excluded.relay_uid,
|
||||||
bucket_uid = excluded.bucket_uid,
|
bucket_uid = excluded.bucket_uid,
|
||||||
last_push_at = excluded.last_push_at
|
last_push_at = excluded.last_push_at,
|
||||||
|
last_seen_at = excluded.last_seen_at
|
||||||
");
|
");
|
||||||
|
|
||||||
foreach ($dirty as $card) {
|
foreach ($dirty as $card) {
|
||||||
@@ -459,6 +478,31 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$payload = collabEncryptPayload(collabBuildCardPayload($card));
|
$payload = collabEncryptPayload(collabBuildCardPayload($card));
|
||||||
if ($payload === null) continue; // no relay_key or openssl failure
|
if ($payload === null) continue; // no relay_key or openssl failure
|
||||||
|
|
||||||
|
// Attachments FIRST, card second. If a peer polls between the two
|
||||||
|
// HTTP calls, they'd see the new card body with an `uploads/xyz.png`
|
||||||
|
// reference but the attachment wouldn't be on the relay yet — their
|
||||||
|
// fetch returns empty and the image renders broken permanently
|
||||||
|
// (the pull short-circuits on the card's next poll because
|
||||||
|
// updated_at doesn't change). Pushing attachments first guarantees
|
||||||
|
// the relay's state is consistent: if the card is visible, its
|
||||||
|
// images are fetchable.
|
||||||
|
$body = $card['body'] ?? '';
|
||||||
|
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$localPath = UPLOAD_DIR . '/' . $fn;
|
||||||
|
if (!is_file($localPath)) continue;
|
||||||
|
$bytes = file_get_contents($localPath);
|
||||||
|
if ($bytes === false) continue;
|
||||||
|
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
|
||||||
|
if ($encoded === null) continue;
|
||||||
|
collabRelayCall('/api/collab-attachments', 'POST', [
|
||||||
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
|
'filename' => $fn,
|
||||||
|
'data' => $encoded,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$res = collabRelayCall('/api/collab-cards', 'POST', [
|
$res = collabRelayCall('/api/collab-cards', 'POST', [
|
||||||
'bucket_uid' => $card['bucket_uid'],
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
'card_uid' => $relayUid,
|
'card_uid' => $relayUid,
|
||||||
@@ -483,6 +527,7 @@ function collabSyncCards(PDO $db): array {
|
|||||||
'ru' => $relayUid,
|
'ru' => $relayUid,
|
||||||
'bu' => $card['bucket_uid'],
|
'bu' => $card['bucket_uid'],
|
||||||
'push' => $res['updated_at'] ?? $card['updated_at'],
|
'push' => $res['updated_at'] ?? $card['updated_at'],
|
||||||
|
'push2'=> $res['updated_at'] ?? $card['updated_at'],
|
||||||
]);
|
]);
|
||||||
$stats['pushed']++;
|
$stats['pushed']++;
|
||||||
}
|
}
|
||||||
@@ -543,6 +588,33 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$fields = collabDecryptPayload($c['payload'] ?? '');
|
$fields = collabDecryptPayload($c['payload'] ?? '');
|
||||||
if ($fields === null) continue;
|
if ($fields === null) continue;
|
||||||
|
|
||||||
|
// Fetch referenced attachments BEFORE applying the body so that
|
||||||
|
// the moment state.cards reflects the new body (via _reloadAll),
|
||||||
|
// every `uploads/xxx.png` reference is already on disk. Without
|
||||||
|
// this, a user who opens the card preview between apply and
|
||||||
|
// fetch would get a broken-image render — and the browser's
|
||||||
|
// 404 response can stick around in the page's image cache even
|
||||||
|
// after the file finally lands.
|
||||||
|
$pulledBody = $fields['body'] ?? '';
|
||||||
|
if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) {
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$dest = $uploadDir . '/' . $fn;
|
||||||
|
if (file_exists($dest)) continue;
|
||||||
|
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn));
|
||||||
|
if (!($attRes['ok'] ?? false)) continue;
|
||||||
|
foreach (($attRes['attachments'] ?? []) as $a) {
|
||||||
|
if (($a['filename'] ?? '') !== $fn) continue;
|
||||||
|
$decoded = collabDecryptPayload($a['data'] ?? '');
|
||||||
|
if ($decoded && isset($decoded['raw'])) {
|
||||||
|
$bytes = base64_decode($decoded['raw']);
|
||||||
|
if ($bytes !== false) file_put_contents($dest, $bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($local) {
|
if ($local) {
|
||||||
// LWW: only accept remote if it's strictly newer than local
|
// LWW: only accept remote if it's strictly newer than local
|
||||||
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
|
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
|
||||||
@@ -602,24 +674,49 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$localId = (int)$db->lastInsertId();
|
$localId = (int)$db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the remote state so we don't push it back on the next poll
|
// Record the remote state so we don't push it back on the next
|
||||||
|
// poll. last_seen_at semantics: self-authored updates (our own
|
||||||
|
// echo) mark as seen; foreign-authored updates leave last_seen_at
|
||||||
|
// untouched so the red dot fires on the board.
|
||||||
|
$seenAt = $foreignAuthor ? null : $c['updated_at'];
|
||||||
$db->prepare("
|
$db->prepare("
|
||||||
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
|
||||||
VALUES (:cid, :ru, :bu, :p, :r)
|
VALUES (:cid, :ru, :bu, :p, :r, :s)
|
||||||
ON CONFLICT(card_id) DO UPDATE SET
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
relay_uid = excluded.relay_uid,
|
relay_uid = excluded.relay_uid,
|
||||||
bucket_uid = excluded.bucket_uid,
|
bucket_uid = excluded.bucket_uid,
|
||||||
last_push_at = excluded.last_push_at,
|
last_push_at = excluded.last_push_at,
|
||||||
last_remote_at = excluded.last_remote_at
|
last_remote_at = excluded.last_remote_at,
|
||||||
|
last_seen_at = CASE
|
||||||
|
WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at
|
||||||
|
ELSE collab_card_state.last_seen_at
|
||||||
|
END
|
||||||
")->execute([
|
")->execute([
|
||||||
'cid' => $localId,
|
'cid' => $localId,
|
||||||
'ru' => $c['card_uid'],
|
'ru' => $c['card_uid'],
|
||||||
'bu' => $c['bucket_uid'],
|
'bu' => $c['bucket_uid'],
|
||||||
'p' => $c['updated_at'],
|
'p' => $c['updated_at'],
|
||||||
'r' => $c['updated_at'],
|
'r' => $c['updated_at'],
|
||||||
|
's' => $seenAt,
|
||||||
]);
|
]);
|
||||||
$stats['pulled']++;
|
$stats['pulled']++;
|
||||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
|
|
||||||
|
// Notify the card's author that we synced it (best-effort sync
|
||||||
|
// event → toast on the author's side). Only fires for cards
|
||||||
|
// authored by someone else.
|
||||||
|
if ($foreignAuthor && !empty($c['author_username'])) {
|
||||||
|
$cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)';
|
||||||
|
$evRes = collabRelayCall('/api/sync-events', 'POST', [
|
||||||
|
'recipient' => $c['author_username'],
|
||||||
|
'body' => $selfUser . ' synced: ' . $cardTitle,
|
||||||
|
]);
|
||||||
|
@error_log('[hyprcrd] sync-event POST → ' . $c['author_username']
|
||||||
|
. ' ok=' . ($evRes['ok'] ?? 'n/a')
|
||||||
|
. ' status=' . ($evRes['status'] ?? '-')
|
||||||
|
. ' err=' . substr((string)($evRes['error'] ?? ''), 0, 120));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Bump `collab_buckets.updated_at` for every bucket that received
|
// Bump `collab_buckets.updated_at` for every bucket that received
|
||||||
@@ -641,6 +738,40 @@ function collabSyncCards(PDO $db): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
||||||
|
|
||||||
|
// Self-heal phase: walk every card in a collab bucket and try to fetch
|
||||||
|
// any `uploads/xxx.png` reference whose file isn't on disk. Covers the
|
||||||
|
// historical race where a card body arrived before its attachment, as
|
||||||
|
// well as any one-off fetch failure (network blip, timeout) that left
|
||||||
|
// a card with a broken image reference. Normal-case cost is trivial:
|
||||||
|
// one SQL, one `file_exists()` per reference, no HTTP when everything
|
||||||
|
// is already in place.
|
||||||
|
$missing = $db->query("
|
||||||
|
SELECT c.id, c.body, b.collab_uid AS bucket_uid
|
||||||
|
FROM cards c
|
||||||
|
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
||||||
|
WHERE c.body LIKE '%uploads/%'
|
||||||
|
")->fetchAll();
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
|
||||||
|
foreach ($missing as $mc) {
|
||||||
|
if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue;
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$dest = $uploadDir . '/' . $fn;
|
||||||
|
if (file_exists($dest)) continue;
|
||||||
|
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn));
|
||||||
|
if (!($attRes['ok'] ?? false)) continue;
|
||||||
|
foreach (($attRes['attachments'] ?? []) as $a) {
|
||||||
|
if (($a['filename'] ?? '') !== $fn) continue;
|
||||||
|
$decoded = collabDecryptPayload($a['data'] ?? '');
|
||||||
|
if ($decoded && isset($decoded['raw'])) {
|
||||||
|
$bytes = base64_decode($decoded['raw']);
|
||||||
|
if ($bytes !== false) file_put_contents($dest, $bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,6 +968,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
// Any pending outbox entries still waiting for a reachable relay
|
// Any pending outbox entries still waiting for a reachable relay
|
||||||
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
|
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
|
||||||
|
|
||||||
|
// Per-card unread set: cards whose local updated_at is newer than
|
||||||
|
// their last_seen_at (or never seen). Frontend uses this to draw
|
||||||
|
// the little red dot on the board card.
|
||||||
|
$unreadCards = array_map('intval', $db->query("
|
||||||
|
SELECT cs.card_id
|
||||||
|
FROM collab_card_state cs
|
||||||
|
JOIN cards c ON c.id = cs.card_id
|
||||||
|
WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at
|
||||||
|
")->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
|
||||||
collabJson([
|
collabJson([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'self' => collabRelaySettings()['user'],
|
'self' => collabRelaySettings()['user'],
|
||||||
@@ -847,6 +988,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
'locks' => $locks,
|
'locks' => $locks,
|
||||||
'outbox_processed' => $outboxProcessed,
|
'outbox_processed' => $outboxProcessed,
|
||||||
'outbox_pending' => $outboxPending,
|
'outbox_pending' => $outboxPending,
|
||||||
|
'unread_cards' => $unreadCards,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,6 +1003,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
collabJson(['ok' => true]);
|
collabJson(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mark a single collab card as seen (clears its red dot) ────
|
||||||
|
if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$cardId = (int)($input['card_id'] ?? 0);
|
||||||
|
if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400);
|
||||||
|
Database::connect()
|
||||||
|
->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c")
|
||||||
|
->execute(['c' => $cardId]);
|
||||||
|
collabJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Proxy: relay tenants (for the create-bucket member picker) ──
|
// ── Proxy: relay tenants (for the create-bucket member picker) ──
|
||||||
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
|
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
|
||||||
// so we wrap it here to match the shape the frontend expects.
|
// so we wrap it here to match the shape the frontend expects.
|
||||||
|
|||||||
@@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
|
|||||||
.collab-conflict-toast.collab-lock-toast b {
|
.collab-conflict-toast.collab-lock-toast b {
|
||||||
color: var(--t-accent2);
|
color: var(--t-accent2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Per-card unread indicator ──
|
||||||
|
Mirror of the bucket-tab red dot: a card with a foreign edit we haven't
|
||||||
|
opened yet gets a small glowing dot pinned to the top-right corner. */
|
||||||
|
.board-card.collab-card-unread { position: relative; }
|
||||||
|
.board-card.collab-card-unread .collab-card-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
border: 1px solid var(--t-bg);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
// rows in the standard bucket bar. The plugin just owns the sync + the
|
// rows in the standard bucket bar. The plugin just owns the sync + the
|
||||||
// visual unread-state decoration:
|
// visual unread-state decoration:
|
||||||
//
|
//
|
||||||
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
|
// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
|
||||||
// mirrors each remote bucket into the main `buckets` table so the core
|
// in sync.js). The backend mirrors each remote bucket into the main
|
||||||
// bucket bar renders it automatically.
|
// `buckets` table so the core bucket bar renders it automatically.
|
||||||
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
||||||
// the core + Bucket button calls so we can pop a richer "new bucket"
|
// the core + Bucket button calls so we can pop a richer "new bucket"
|
||||||
// modal with an optional "collaborative" checkbox + member picker.
|
// modal with an optional "collaborative" checkbox + member picker.
|
||||||
@@ -24,8 +24,11 @@ const $ = (id) => document.getElementById(id);
|
|||||||
|
|
||||||
// uid -> collab bucket meta (from /api/collab-buckets)
|
// uid -> collab bucket meta (from /api/collab-buckets)
|
||||||
let collabByUid = new Map();
|
let collabByUid = new Map();
|
||||||
let pollHandle = null;
|
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
||||||
const POLL_INTERVAL_MS = 30000;
|
let unreadCards = new Set();
|
||||||
|
// Collab card sync is driven by the shared `sync.tick` hook from
|
||||||
|
// sync.js — one timer fans out to every plugin poller, the central
|
||||||
|
// timer honors the Autosync pause for us.
|
||||||
|
|
||||||
// Phase 4: edit locks (now exclusive — server-enforced)
|
// Phase 4: edit locks (now exclusive — server-enforced)
|
||||||
// card_uid -> { bucket_uid, holder_username, expires_at }
|
// card_uid -> { bucket_uid, holder_username, expires_at }
|
||||||
@@ -58,8 +61,16 @@ async function loadCollabMeta() {
|
|||||||
} catch (err) { /* silent; relay may be offline */ }
|
} catch (err) { /* silent; relay may be offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pollLocked = false;
|
||||||
|
|
||||||
async function pollOnce() {
|
async function pollOnce() {
|
||||||
|
if (pollLocked) return;
|
||||||
|
// Don't poll while user is actively editing — the push would race
|
||||||
|
// with the in-progress save and corrupt card state.
|
||||||
|
if (document.getElementById('modal')?.classList.contains('open')) return;
|
||||||
try {
|
try {
|
||||||
|
pollLocked = true;
|
||||||
|
hyprcrd.hooks?.emit?.('sync.pulse');
|
||||||
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
if (res.self) window._collabSelf = res.self;
|
if (res.self) window._collabSelf = res.self;
|
||||||
@@ -85,6 +96,12 @@ async function pollOnce() {
|
|||||||
const n = res.outbox_processed;
|
const n = res.outbox_processed;
|
||||||
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
||||||
}
|
}
|
||||||
|
// Per-card unread list: cards with foreign edits we haven't opened
|
||||||
|
// yet. Decorated on the board via decorateBoardUnread().
|
||||||
|
if (Array.isArray(res.unread_cards)) {
|
||||||
|
unreadCards = new Set(res.unread_cards.map(Number));
|
||||||
|
decorateBoardUnread();
|
||||||
|
}
|
||||||
// Phase 4: edit-lock list comes back with the poll
|
// Phase 4: edit-lock list comes back with the poll
|
||||||
if (Array.isArray(res.locks)) {
|
if (Array.isArray(res.locks)) {
|
||||||
const next = new Map();
|
const next = new Map();
|
||||||
@@ -112,7 +129,7 @@ async function pollOnce() {
|
|||||||
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
|
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
|
||||||
showBlockedToast(by);
|
showBlockedToast(by);
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ } finally { pollLocked = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showConflictToast(n) {
|
function showConflictToast(n) {
|
||||||
@@ -149,13 +166,11 @@ function showQueuedToast(message) {
|
|||||||
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
// Subscribe to the global sync heartbeat. The central timer in sync.js
|
||||||
stopPolling();
|
// honors the Autosync pause + the configurable interval for us, and
|
||||||
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
|
// the header Sync button emits the same tick so manual sync pushes
|
||||||
}
|
// collab cards too.
|
||||||
function stopPolling() {
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bucket bar decoration ─────────────────────────────────────────
|
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -631,12 +646,71 @@ function decorateBoardLocks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every rendered board card and add / remove the "red dot" unread
|
||||||
|
* indicator for cards with foreign edits we haven't opened yet.
|
||||||
|
* Idempotent — safe to re-run on every board re-render.
|
||||||
|
*/
|
||||||
|
function decorateBoardUnread() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
for (const el of board.querySelectorAll('.board-card[data-id]')) {
|
||||||
|
const cid = parseInt(el.dataset.id);
|
||||||
|
const isUnread = unreadCards.has(cid);
|
||||||
|
el.classList.toggle('collab-card-unread', isUnread);
|
||||||
|
let dot = el.querySelector('.collab-card-dot');
|
||||||
|
if (isUnread && !dot) {
|
||||||
|
dot = document.createElement('span');
|
||||||
|
dot.className = 'collab-card-dot';
|
||||||
|
el.appendChild(dot);
|
||||||
|
} else if (!isUnread && dot) {
|
||||||
|
dot.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a card's unread state locally + on the backend. Called from
|
||||||
|
// - board click (piggy-backing on the natural open-card interaction)
|
||||||
|
// - detail.opened hook (when the detail view opens via any path:
|
||||||
|
// keyboard shortcut, related-card link, backlink, search result, etc.)
|
||||||
|
function clearCardUnread(cid) {
|
||||||
|
cid = parseInt(cid);
|
||||||
|
if (!cid || !unreadCards.has(cid)) return;
|
||||||
|
unreadCards.delete(cid);
|
||||||
|
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.remove('collab-card-unread');
|
||||||
|
el.querySelector('.collab-card-dot')?.remove();
|
||||||
|
}
|
||||||
|
hyprcrd.api('api/collab-buckets/mark-card-seen', {
|
||||||
|
method: 'POST', body: { card_id: cid },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCardClickInterceptor() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (board) {
|
||||||
|
board.addEventListener('click', (e) => {
|
||||||
|
const el = e.target.closest('.board-card[data-id]');
|
||||||
|
if (el) clearCardUnread(el.dataset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Detail view has its own open path (keyboard shortcuts, backlinks,
|
||||||
|
// related-card navigation) that doesn't route through a board-card
|
||||||
|
// click. Listen for the explicit detail.opened hook so every open
|
||||||
|
// method clears the dot.
|
||||||
|
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Re-decorate whenever the board re-renders (reuses the existing #board
|
// Re-decorate whenever the board re-renders (reuses the existing #board
|
||||||
// MutationObserver pattern).
|
// MutationObserver pattern).
|
||||||
function setupBoardObserver() {
|
function setupBoardObserver() {
|
||||||
const board = document.getElementById('board');
|
const board = document.getElementById('board');
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
const obs = new MutationObserver(() => decorateBoardLocks());
|
const obs = new MutationObserver(() => {
|
||||||
|
decorateBoardLocks();
|
||||||
|
decorateBoardUnread();
|
||||||
|
});
|
||||||
obs.observe(board, { childList: true, subtree: false });
|
obs.observe(board, { childList: true, subtree: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,19 +966,30 @@ async function init() {
|
|||||||
}
|
}
|
||||||
setupBarObserver();
|
setupBarObserver();
|
||||||
setupClickInterceptor();
|
setupClickInterceptor();
|
||||||
|
setupCardClickInterceptor();
|
||||||
setupBoardObserver();
|
setupBoardObserver();
|
||||||
await loadCollabMeta();
|
await loadCollabMeta();
|
||||||
await pollOnce();
|
await pollOnce();
|
||||||
await loadCollabMeta();
|
await loadCollabMeta();
|
||||||
decorateBucketBar();
|
decorateBucketBar();
|
||||||
decorateBoardLocks();
|
decorateBoardLocks();
|
||||||
startPolling();
|
decorateBoardUnread();
|
||||||
|
// No local timer: subsequent polls come from the global `sync.tick`
|
||||||
|
// hook (central timer in sync.js honors Autosync pause + interval).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
|
||||||
|
// boot, and firing POST /api/collab-buckets/poll before that racing lookup
|
||||||
|
// resolves returns 403. If the token is already in the util state when we
|
||||||
|
// load (e.g. fast reload, cached session), kick off init straight away.
|
||||||
|
function bootWhenCsrfReady() {
|
||||||
|
if (hyprcrd?.util?.csrf?.()) { init(); return; }
|
||||||
|
hyprcrd?.hooks?.on?.('csrf.ready', () => init());
|
||||||
|
}
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
document.addEventListener('DOMContentLoaded', bootWhenCsrfReady, { once: true });
|
||||||
} else {
|
} else {
|
||||||
init();
|
bootWhenCsrfReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
hyprcrd.registerPlugin?.({
|
hyprcrd.registerPlugin?.({
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
<i class="ph ph-envelope text-t-accent text-base"></i>
|
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
||||||
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||||
<button id="email-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
<button id="email-refresh" class="toolbar-btn" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="manage accounts"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||||
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
<div class="sticky top-0 z-20 bg-t-bg">
|
<div class="sticky top-0 z-20 bg-t-bg">
|
||||||
<!-- List toolbar: pagination + spam quick-select -->
|
<!-- List toolbar: pagination + spam quick-select -->
|
||||||
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
||||||
<button id="email-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="email-page-prev" class="toolbar-btn" data-tip="previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
<button id="email-page-next" class="toolbar-btn" data-tip="next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Select all messages flagged as spam">
|
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="select spam">
|
||||||
<i class="ph ph-warning-octagon text-sm"></i>
|
<i class="ph ph-warning-octagon text-sm"></i>
|
||||||
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -54,16 +54,16 @@
|
|||||||
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
||||||
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Mark sender as spam (future mail from same sender will be flagged)">
|
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="mark as spam">
|
||||||
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)">
|
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" data-tip="not spam">
|
||||||
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server">
|
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="Delete selected on server">
|
||||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection">
|
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Clear selection">
|
||||||
<i class="ph ph-x text-sm"></i>
|
<i class="ph ph-x text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
||||||
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
|
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
|
||||||
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
|
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
|
||||||
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
|
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" data-tip="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
|
||||||
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -218,9 +218,9 @@ function renderAccountsList() {
|
|||||||
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
|
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-account-actions">
|
<div class="email-account-actions">
|
||||||
<button class="toolbar-btn email-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button class="toolbar-btn email-edit-btn" data-tip="edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
<button class="toolbar-btn email-test-btn" data-tip="test"><i class="ph ph-plug text-sm"></i></button>
|
||||||
<button class="toolbar-btn email-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="toolbar-btn email-del-btn" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -452,13 +452,13 @@ function renderMessageList() {
|
|||||||
const spamScore = m.spam?.score ?? 0;
|
const spamScore = m.spam?.score ?? 0;
|
||||||
const spamReasons = (m.spam?.reasons || []).join(' · ');
|
const spamReasons = (m.spam?.reasons || []).join(' · ');
|
||||||
const spamBadge = spamLevel === 'spam'
|
const spamBadge = spamLevel === 'spam'
|
||||||
? `<span class="email-spam-badge spam" title="${escapeHtml(spamReasons)}">⚠ spam</span>`
|
? `<span class="email-spam-badge spam" data-tip="${escapeHtml(spamReasons)}">⚠ spam</span>`
|
||||||
: spamLevel === 'suspicious'
|
: spamLevel === 'suspicious'
|
||||||
? `<span class="email-spam-badge suspicious" title="${escapeHtml(spamReasons)}">? suspicious</span>`
|
? `<span class="email-spam-badge suspicious" data-tip="${escapeHtml(spamReasons)}">? suspicious</span>`
|
||||||
: '';
|
: '';
|
||||||
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
|
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
|
||||||
return `
|
return `
|
||||||
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" title="${escapeHtml(rowTitle)}">
|
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" data-tip="${escapeHtml(rowTitle)}">
|
||||||
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
|
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
|
||||||
<div class="email-msg-content">
|
<div class="email-msg-content">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// ── Example content plugin — one-click demo data ──
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const api = h?.api || window._exampleApi;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
blue: '#38bdf8', purple: '#a78bfa', pink: '#f472b6', red: '#fb7185',
|
||||||
|
green: '#34d399', yellow: '#fbbf24', orange: '#fb923c', slate: '#64748b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{
|
||||||
|
title: 'Architecture RFC',
|
||||||
|
color: COLORS.blue,
|
||||||
|
body: `## System Architecture
|
||||||
|
|
||||||
|
The new microservice layout separates auth, data, and notification layers.
|
||||||
|
|
||||||
|
### Key decisions
|
||||||
|
- PostgreSQL for persistent state
|
||||||
|
- Redis for session cache + pub/sub
|
||||||
|
- gRPC between internal services
|
||||||
|
- REST + WebSocket for client API
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
- [ ] Rate limiting strategy
|
||||||
|
- [ ] Multi-region failover
|
||||||
|
- [x] Auth token rotation scheme
|
||||||
|
- [x] Database migration tooling
|
||||||
|
|
||||||
|
See also: [[Deploy checklist]]
|
||||||
|
|
||||||
|
#project/api #architecture`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sprint Planning Q3',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `## Goals
|
||||||
|
|
||||||
|
1. Finish API v3 migration
|
||||||
|
2. Launch mobile auth flow
|
||||||
|
3. Load testing at 10k concurrent
|
||||||
|
4. Cut staging→prod deploy time to <5min
|
||||||
|
|
||||||
|
**Deadline:** 2026-07-01
|
||||||
|
**Owner:** @alice
|
||||||
|
|
||||||
|
### Capacity
|
||||||
|
| Team | Points | Allocated |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Backend | 34 | 30 |
|
||||||
|
| Frontend | 21 | 18 |
|
||||||
|
| Infra | 13 | 13 |
|
||||||
|
|
||||||
|
#meeting #project`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Auth timeout on mobile',
|
||||||
|
color: COLORS.red,
|
||||||
|
body: `Users on iOS Safari report being logged out after ~3 minutes of inactivity.
|
||||||
|
|
||||||
|
### Repro
|
||||||
|
1. Login on iPhone 15 (Safari 18)
|
||||||
|
2. Switch to another app for 3 min
|
||||||
|
3. Return → "session expired"
|
||||||
|
|
||||||
|
### Investigation
|
||||||
|
- Cookie \`SameSite=Strict\` might be too aggressive
|
||||||
|
- Safari's ITP may be purging the session cookie
|
||||||
|
- Backend session TTL is 24h — not the issue
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Try \`SameSite=Lax\` + short-lived refresh token.
|
||||||
|
|
||||||
|
#bug #urgent #project/frontend/auth`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Deploy checklist',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `- [x] Run full test suite
|
||||||
|
- [x] Update CHANGELOG.md
|
||||||
|
- [x] Bump version in config
|
||||||
|
- [ ] Tag release in git
|
||||||
|
- [ ] Build Docker image
|
||||||
|
- [ ] Push to registry
|
||||||
|
- [ ] Roll out to staging
|
||||||
|
- [ ] Smoke test staging
|
||||||
|
- [ ] Promote to production
|
||||||
|
- [ ] Monitor Grafana for 30min
|
||||||
|
|
||||||
|
> Last deploy: 2026-04-08 by @bob
|
||||||
|
> Rollback: \`git revert HEAD && ./deploy.sh\`
|
||||||
|
|
||||||
|
#ops #deploy`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Meeting 2026-04-15 — Retro',
|
||||||
|
color: COLORS.yellow,
|
||||||
|
due_date: '2026-04-15',
|
||||||
|
body: `**Zeit:** 14:00-15:00
|
||||||
|
**Ort:** Room 42
|
||||||
|
**Teilnehmer:** Alice, Bob, Carol
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### What went well
|
||||||
|
- Deploy frequency improved (3x/week → daily)
|
||||||
|
- Zero P1 incidents this sprint
|
||||||
|
- New search feature got good feedback
|
||||||
|
|
||||||
|
### What to improve
|
||||||
|
- PR review turnaround still >24h
|
||||||
|
- Flaky integration tests causing false alarms
|
||||||
|
- Documentation gaps in relay module
|
||||||
|
|
||||||
|
### Action items
|
||||||
|
- [ ] @alice: set up PR review SLA
|
||||||
|
- [ ] @bob: fix top 5 flaky tests
|
||||||
|
- [ ] @carol: write relay onboarding doc
|
||||||
|
|
||||||
|
#meeting #termin #retro`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reading list',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `Papers and articles to read:
|
||||||
|
|
||||||
|
- [Designing Data-Intensive Applications](https://dataintensive.net/) — Ch. 7-9
|
||||||
|
- [The Twelve-Factor App](https://12factor.net/)
|
||||||
|
- \`CAP theorem revisited\` — Brewer 2012
|
||||||
|
- Local-first software — Kleppmann et al.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
> "The truth is the log. The database is a cache."
|
||||||
|
> — Pat Helland
|
||||||
|
|
||||||
|
#personal #research`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API v3 migration',
|
||||||
|
color: COLORS.blue,
|
||||||
|
body: `### Endpoints to migrate
|
||||||
|
|
||||||
|
| v2 | v3 | Status |
|
||||||
|
|----|-----|--------|
|
||||||
|
| GET /users | GET /api/v3/users | done |
|
||||||
|
| POST /auth | POST /api/v3/auth/token | done |
|
||||||
|
| GET /cards | GET /api/v3/cards | in progress |
|
||||||
|
| WS /stream | WS /api/v3/events | planned |
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
- Auth header: \`Bearer\` instead of \`X-Token\`
|
||||||
|
- Pagination: cursor-based instead of offset
|
||||||
|
- Error format: \`{"error": {"code": ..., "message": ...}}\`
|
||||||
|
|
||||||
|
[[Architecture RFC]]
|
||||||
|
|
||||||
|
#project/api #migration`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Infra cost review',
|
||||||
|
color: COLORS.orange,
|
||||||
|
body: `### Monthly breakdown (April)
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
Compute (k8s) €340 ████████░░ 68%
|
||||||
|
Storage (S3) €85 ██░░░░░░░░ 17%
|
||||||
|
Database (RDS) €52 █░░░░░░░░░ 10%
|
||||||
|
CDN + DNS €18 ░░░░░░░░░░ 4%
|
||||||
|
Monitoring €8 ░░░░░░░░░░ 2%
|
||||||
|
─────────────────────────────────────
|
||||||
|
Total €503
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Budget: €600 → **€97 headroom**
|
||||||
|
|
||||||
|
Next action: evaluate spot instances for batch workers.
|
||||||
|
|
||||||
|
#ops #infra #finance`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Birthday Alice',
|
||||||
|
color: COLORS.pink,
|
||||||
|
due_date: '2026-06-12',
|
||||||
|
body: `#geburtstag`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Onboarding: new developer',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `## First week checklist
|
||||||
|
|
||||||
|
- [ ] Laptop + accounts provisioned
|
||||||
|
- [ ] SSH key added to Gitea
|
||||||
|
- [ ] Access to staging cluster
|
||||||
|
- [x] Slack channels joined
|
||||||
|
- [x] 1:1 with team lead
|
||||||
|
- [ ] Read [[Architecture RFC]]
|
||||||
|
- [ ] First PR: fix a good-first-issue
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
| Role | Person |
|
||||||
|
|------|--------|
|
||||||
|
| Buddy | @carol |
|
||||||
|
| Team lead | @alice |
|
||||||
|
| Infra | @dave |
|
||||||
|
|
||||||
|
#onboarding #ops`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Competitive analysis',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `### Landscape
|
||||||
|
|
||||||
|
| Product | Self-hosted | Offline | E2E encryption |
|
||||||
|
|---------|:-----------:|:-------:|:--------------:|
|
||||||
|
| Notion | no | no | no |
|
||||||
|
| Obsidian| yes | yes | paid |
|
||||||
|
| Logseq | yes | yes | no |
|
||||||
|
| **hyprcrd** | **yes** | **yes** | **yes** |
|
||||||
|
|
||||||
|
### Differentiators
|
||||||
|
- Tag extraction from body (zero-friction categorization)
|
||||||
|
- Relay model: multi-tenant sync without cloud dependency
|
||||||
|
- Plugin system: extensible without forking
|
||||||
|
- CalDAV native (not just ical export)
|
||||||
|
|
||||||
|
#research #strategy`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Security audit notes',
|
||||||
|
color: COLORS.red,
|
||||||
|
body: `## Audit 2026-Q1 findings
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
~~1. Session token in URL parameter~~ → fixed in v1.2.1
|
||||||
|
|
||||||
|
### High
|
||||||
|
2. CSRF token not rotated on privilege change → [[Auth timeout on mobile]]
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
3. Upload directory allows .svg (XSS vector)
|
||||||
|
4. Rate limiter not applied to /api/export
|
||||||
|
|
||||||
|
### Low
|
||||||
|
5. Server version exposed in HTTP headers
|
||||||
|
6. No CSP header on main app
|
||||||
|
|
||||||
|
> Next audit scheduled: 2026-07-15
|
||||||
|
|
||||||
|
#security #audit #urgent`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Design system tokens',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `### Color palette
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
--t-bg: #0f172a base background
|
||||||
|
--t-surface: #1e293b card/panel surface
|
||||||
|
--t-border: #334155 borders
|
||||||
|
--t-text: #94a3b8 body text
|
||||||
|
--t-bright: #e2e8f0 headlines
|
||||||
|
--t-accent: #38bdf8 primary accent
|
||||||
|
--t-accent2: #a78bfa secondary accent
|
||||||
|
--t-danger: #fb7185 errors/destructive
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- **UI:** Silkscreen (pixel font)
|
||||||
|
- **Content:** JetBrains Mono
|
||||||
|
- **Sizes:** 9px labels, 11px body, 13px headings
|
||||||
|
|
||||||
|
### Radius scale
|
||||||
|
\`--ui-radius-card\`, \`--ui-radius-modal\`, \`--ui-radius-input\`
|
||||||
|
|
||||||
|
All overridable per theme preset.
|
||||||
|
|
||||||
|
#design #frontend`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Customer feedback digest',
|
||||||
|
color: COLORS.yellow,
|
||||||
|
body: `### Week of 2026-04-07
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- "Search is blazing fast" — 3 mentions
|
||||||
|
- "CalDAV sync just works" — 2 mentions
|
||||||
|
- "Love the keyboard shortcuts" — @user42
|
||||||
|
|
||||||
|
**Requests:**
|
||||||
|
- Mobile app (→ Electron PWA?)
|
||||||
|
- Shared boards without relay (→ collab-buckets?)
|
||||||
|
- Dark mode for relay UI (→ already shipped!)
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Image upload fails >5MB on shared hosting
|
||||||
|
- Calendar popup overlaps on 13" screens
|
||||||
|
|
||||||
|
#feedback #product`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Database schema notes',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `### Core tables
|
||||||
|
|
||||||
|
\`\`\`sql
|
||||||
|
cards (id, title, body, color, tags,
|
||||||
|
bucket_id, parent_id, position,
|
||||||
|
due_date, relay_uid,
|
||||||
|
created_at, updated_at)
|
||||||
|
|
||||||
|
buckets (id, name, color, parent_id,
|
||||||
|
collab_uid, position)
|
||||||
|
|
||||||
|
settings (key, value)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
- \`cards.bucket_id\` — bucket filter
|
||||||
|
- \`cards.parent_id\` — group lookup
|
||||||
|
- \`buckets.collab_uid UNIQUE\` — relay sync
|
||||||
|
|
||||||
|
SQLite WAL mode for concurrent reads.
|
||||||
|
|
||||||
|
#architecture #database`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Release notes v1.3',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `## What's new
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Tooltip system** — custom styled tooltips replace all native titles
|
||||||
|
- **Asset picker** — thumbnail grid in the editor toolbar
|
||||||
|
- **Super-bucket split** — click activates, caret opens dropdown
|
||||||
|
- **Watch plugin** — analog clock overlay with SVG watchface
|
||||||
|
- **Card peek** — hover preview with rendered markdown
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Shortcuts no longer fire when typing in inputs
|
||||||
|
- Gallery opens even if API fetch fails
|
||||||
|
- Relay poll suppressed during user actions
|
||||||
|
- Find/replace integrated into search overlay
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- All \`title=""\` attributes migrated to \`data-tip=""\`
|
||||||
|
|
||||||
|
#release #changelog`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2026-04-12',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `## Journal
|
||||||
|
|
||||||
|
Started the day reviewing PRs. Merged the tooltip system and the asset picker.
|
||||||
|
|
||||||
|
Spent the afternoon on the Electron build — static PHP binary works but the WAL/SHM files caused issues. Fixed by deleting stale lock files.
|
||||||
|
|
||||||
|
Had a good session on the watch plugin. The wobble animation on the second hand is a nice touch.
|
||||||
|
|
||||||
|
**Tomorrow:**
|
||||||
|
- [ ] Test relay collab-buckets with two instances
|
||||||
|
- [ ] Write release notes
|
||||||
|
- [ ] Screenshot for website
|
||||||
|
|
||||||
|
#journal`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Backup strategy',
|
||||||
|
color: COLORS.orange,
|
||||||
|
body: `### Current
|
||||||
|
|
||||||
|
Daily cron:
|
||||||
|
\`\`\`bash
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
cp data/hyprcrd.db "/backup/hyprcrd-$DATE.db"
|
||||||
|
tar czf "/backup/uploads-$DATE.tar.gz" public/uploads/
|
||||||
|
find /backup -mtime +30 -delete
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- [ ] Automated export ZIP via API
|
||||||
|
- [ ] Offsite copy to S3-compatible storage
|
||||||
|
- [ ] Test restore monthly
|
||||||
|
- [ ] Document in runbook
|
||||||
|
|
||||||
|
> WAL checkpoint before copy:
|
||||||
|
> \`sqlite3 data/hyprcrd.db "PRAGMA wal_checkpoint(TRUNCATE);"\`
|
||||||
|
|
||||||
|
#ops #backup #infra`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugin ideas',
|
||||||
|
color: COLORS.pink,
|
||||||
|
body: `### Brainstorm
|
||||||
|
|
||||||
|
- **Kanban columns** — cards as swimlanes per bucket
|
||||||
|
- **Time tracking** — start/stop timer per card, weekly report
|
||||||
|
- **AI summary** — auto-generate card summaries with local LLM
|
||||||
|
- **RSS reader** — fetch feeds into cards
|
||||||
|
- **Webhook triggers** — POST on card create/update/delete
|
||||||
|
- **PDF export** — render card as styled PDF
|
||||||
|
- **Voice memo** — record audio, attach to card, transcribe
|
||||||
|
|
||||||
|
### Criteria for inclusion
|
||||||
|
1. Does it help *find* information?
|
||||||
|
2. Can it be a drop-in plugin (no core changes)?
|
||||||
|
3. Is it useful without an internet connection?
|
||||||
|
|
||||||
|
#idea #plugins #brainstorm`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Monitoring setup',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `### Stack
|
||||||
|
|
||||||
|
- **Uptime:** UptimeRobot (free tier, 5min interval)
|
||||||
|
- **Errors:** Sentry self-hosted → \`sentry.internal\`
|
||||||
|
- **Metrics:** Grafana + Prometheus
|
||||||
|
- **Logs:** Loki + promtail on each node
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
| Metric | Threshold | Channel |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| HTTP 5xx rate | >1% for 5min | Slack #ops-alerts |
|
||||||
|
| Response time p95 | >2s for 10min | Slack #ops-alerts |
|
||||||
|
| Disk usage | >85% | Email + Slack |
|
||||||
|
| SSL expiry | <14 days | Email |
|
||||||
|
|
||||||
|
Dashboard: \`grafana.internal/d/hyprcrd\`
|
||||||
|
|
||||||
|
#ops #monitoring #infra`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const bucket = await api('api/buckets', { method: 'POST', body: { name: 'Project Alpha' } });
|
||||||
|
const bid = bucket?.id;
|
||||||
|
if (!bid) return false;
|
||||||
|
await api(`api/buckets/${bid}`, { method: 'PATCH', body: { color: COLORS.blue } });
|
||||||
|
|
||||||
|
for (const c of CARDS) {
|
||||||
|
await api('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { title: c.title, body: c.body, color: c.color, bucket_id: bid, due_date: c.due_date || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const footer = document.getElementById('main-footer');
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||||
|
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||||
|
btn.dataset.tip = 'Create example bucket with demo cards';
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="ph ph-spinner text-sm"></i><span class="font-pixel text-[10px]">creating...</span>';
|
||||||
|
const ok = await generate();
|
||||||
|
if (ok) {
|
||||||
|
btn.innerHTML = '<i class="ph ph-check text-sm"></i><span class="font-pixel text-[10px]">Done</span>';
|
||||||
|
await window._reloadAll?.();
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Failed</span>';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstChild = footer.querySelector('div');
|
||||||
|
if (firstChild) firstChild.appendChild(btn);
|
||||||
|
|
||||||
|
h?.registerPlugin({ name: 'example-content', version: '1.0.0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "example-content",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "One-click demo content for screenshots and first-time exploration.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["example-content.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,16 @@ require_once __DIR__ . '/GiteaClient.php';
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
|
default_repo TEXT DEFAULT NULL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
");
|
");
|
||||||
|
// Migration: add default_repo column if missing
|
||||||
|
$cols = $db->query("PRAGMA table_info(gitea_accounts)")->fetchAll(PDO::FETCH_COLUMN, 1);
|
||||||
|
if (!in_array('default_repo', $cols)) {
|
||||||
|
$db->exec("ALTER TABLE gitea_accounts ADD COLUMN default_repo TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
||||||
@@ -85,7 +91,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
|
|
||||||
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
$rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||||
giteaJson($rows);
|
giteaJson($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +138,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
if (!empty($input['token'])) {
|
if (!empty($input['token'])) {
|
||||||
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('default_repo', $input)) {
|
||||||
|
$val = trim((string)($input['default_repo'] ?? ''));
|
||||||
|
$fields[] = 'default_repo = :default_repo'; $params['default_repo'] = $val ?: null;
|
||||||
|
}
|
||||||
if (!$fields) giteaJson(['ok' => true]);
|
if (!$fields) giteaJson(['ok' => true]);
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||||
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||||
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,25 +40,25 @@
|
|||||||
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
||||||
<!-- Filters bar -->
|
<!-- Filters bar -->
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||||
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Issue state">
|
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Issue state">
|
||||||
<option value="open">open</option>
|
<option value="open">open</option>
|
||||||
<option value="closed">closed</option>
|
<option value="closed">closed</option>
|
||||||
<option value="all">all</option>
|
<option value="all">all</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Filter by your involvement">
|
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Filter by your involvement">
|
||||||
<option value="all">everyone</option>
|
<option value="all">everyone</option>
|
||||||
<option value="assigned">assigned to me</option>
|
<option value="assigned">assigned to me</option>
|
||||||
<option value="created">created by me</option>
|
<option value="created">created by me</option>
|
||||||
<option value="mentioned">mentioning me</option>
|
<option value="mentioned">mentioning me</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
||||||
<button id="gitea-new-issue" class="toolbar-btn" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
<button id="gitea-new-issue" class="toolbar-btn" data-tip="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
||||||
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
<button id="gitea-refresh" class="toolbar-btn" data-tip="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination toolbar -->
|
<!-- Pagination toolbar -->
|
||||||
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
||||||
<button id="gitea-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="gitea-page-prev" class="toolbar-btn" data-tip="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
<button id="gitea-page-next" class="toolbar-btn" data-tip="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
||||||
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
<button id="gitea-create-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block relative">
|
<label class="block relative">
|
||||||
@@ -140,12 +140,12 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div id="gitea-title-display" class="flex items-center gap-2">
|
<div id="gitea-title-display" class="flex items-center gap-2">
|
||||||
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
||||||
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
||||||
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
||||||
<button id="gitea-title-save" class="toolbar-btn" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
<button id="gitea-title-save" class="toolbar-btn" data-tip="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
||||||
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
<button id="gitea-title-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2 mb-2">
|
<div class="flex items-start gap-2 mb-2">
|
||||||
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
||||||
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
||||||
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
||||||
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
<button id="gitea-meta-due-clear" class="toolbar-btn" title="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
<button id="gitea-meta-due-clear" class="toolbar-btn" data-tip="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||||
<div id="gitea-body-display" class="flex items-start gap-2">
|
<div id="gitea-body-display" class="flex items-start gap-2">
|
||||||
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
||||||
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-body-edit" class="hidden">
|
<div id="gitea-body-edit" class="hidden">
|
||||||
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) {
|
|||||||
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
||||||
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
</label>
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
|
||||||
|
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
</label>
|
||||||
<div class="flex items-center gap-2 pt-1">
|
<div class="flex items-center gap-2 pt-1">
|
||||||
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||||
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 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-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 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-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||||||
@@ -150,12 +154,12 @@ function renderAccountsList() {
|
|||||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||||
<div class="gitea-account-info">
|
<div class="gitea-account-info">
|
||||||
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
||||||
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div>
|
<div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gitea-account-actions">
|
<div class="gitea-account-actions">
|
||||||
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button class="toolbar-btn gitea-edit-btn" data-tip="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
<button class="toolbar-btn gitea-test-btn" data-tip="Test"><i class="ph ph-plug text-sm"></i></button>
|
||||||
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="toolbar-btn gitea-del-btn" data-tip="Delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -201,6 +205,7 @@ function resetAccountForm() {
|
|||||||
$('gitea-acc-name').value = '';
|
$('gitea-acc-name').value = '';
|
||||||
$('gitea-acc-baseurl').value = '';
|
$('gitea-acc-baseurl').value = '';
|
||||||
$('gitea-acc-token').value = '';
|
$('gitea-acc-token').value = '';
|
||||||
|
$('gitea-acc-default-repo').value = '';
|
||||||
setAccountStatus('');
|
setAccountStatus('');
|
||||||
$('gitea-tok-hint').textContent = '';
|
$('gitea-tok-hint').textContent = '';
|
||||||
}
|
}
|
||||||
@@ -214,6 +219,7 @@ function editAccount(id) {
|
|||||||
$('gitea-acc-name').value = acc.name;
|
$('gitea-acc-name').value = acc.name;
|
||||||
$('gitea-acc-baseurl').value = acc.base_url;
|
$('gitea-acc-baseurl').value = acc.base_url;
|
||||||
$('gitea-acc-token').value = '';
|
$('gitea-acc-token').value = '';
|
||||||
|
$('gitea-acc-default-repo').value = acc.default_repo || '';
|
||||||
$('gitea-tok-hint').textContent = '(leave blank to keep existing)';
|
$('gitea-tok-hint').textContent = '(leave blank to keep existing)';
|
||||||
setAccountStatus('');
|
setAccountStatus('');
|
||||||
$('gitea-acc-name').focus();
|
$('gitea-acc-name').focus();
|
||||||
@@ -240,6 +246,7 @@ async function saveAccount(e) {
|
|||||||
name: $('gitea-acc-name').value.trim(),
|
name: $('gitea-acc-name').value.trim(),
|
||||||
base_url: $('gitea-acc-baseurl').value.trim(),
|
base_url: $('gitea-acc-baseurl').value.trim(),
|
||||||
token: $('gitea-acc-token').value,
|
token: $('gitea-acc-token').value,
|
||||||
|
default_repo: $('gitea-acc-default-repo').value.trim(),
|
||||||
};
|
};
|
||||||
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
|
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
|
||||||
setAccountStatus('name, base url, and token are required', 'error');
|
setAccountStatus('name, base url, and token are required', 'error');
|
||||||
@@ -713,8 +720,9 @@ async function openCreateForm() {
|
|||||||
$('gitea-create-form').classList.remove('hidden');
|
$('gitea-create-form').classList.remove('hidden');
|
||||||
$('gitea-create-form').classList.add('flex');
|
$('gitea-create-form').classList.add('flex');
|
||||||
|
|
||||||
// Reset form
|
// Reset form — pre-fill default repo if configured
|
||||||
$('gitea-create-repo').value = '';
|
const currentAccount = accounts.find(a => a.id === currentAccountId);
|
||||||
|
$('gitea-create-repo').value = currentAccount?.default_repo || '';
|
||||||
$('gitea-create-title').value = '';
|
$('gitea-create-title').value = '';
|
||||||
$('gitea-create-body').value = '';
|
$('gitea-create-body').value = '';
|
||||||
setCreateStatus('');
|
setCreateStatus('');
|
||||||
@@ -766,6 +774,8 @@ async function openCreateForm() {
|
|||||||
errBox.classList.remove('hidden');
|
errBox.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentAccount?.default_repo) loadCreateRepoLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRepoHint() {
|
function updateRepoHint() {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# oumta-dashboard plugin
|
||||||
|
|
||||||
|
Fullscreen view for the Oumta cluster dashboard (Go backend in `oumta-dashboard/`).
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **Oumta** button in the hyprcrd top bar (next to *Tasks*)
|
||||||
|
- `window._oumtaToggle()` from the console / other plugins
|
||||||
|
|
||||||
|
Close with **ESC** or the × button.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
- **Overview** — cluster state, workload counts, attention list
|
||||||
|
- **Workloads** — filterable table (name, state, replicas, cpu/mem, image, reason)
|
||||||
|
- **Timeline** — event stream; clicking a workload filters down to its own events
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Open the hyprcrd settings overlay and choose the **oumta** tab:
|
||||||
|
|
||||||
|
- **API Base URL** — e.g. `http://localhost:8080` for local dev, or the cluster-internal URL
|
||||||
|
- **Auth token** — optional, sent as `Authorization: Bearer …`. Will eventually be filled automatically once Keycloak is wired up.
|
||||||
|
- **Poll interval** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
||||||
|
|
||||||
|
## State today
|
||||||
|
|
||||||
|
Everything is mocked. The live indicator in the top right shows `polling` once the overlay is open, but no real HTTP call fires yet. See [`TODO.md`](./TODO.md) for the integration plan.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"oumta-dashboard"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# oumta-dashboard — TODO
|
||||||
|
|
||||||
|
Everything in this file is work the plugin still needs to do before it's more than a mockup. Organised by phase.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Phase 0 (current)**: UI shell + mock data. Everything renders but no backend call is real.
|
||||||
|
- **Phase 1**: connect to real Go backend (`oumta-dashboard` repo) as endpoints become available.
|
||||||
|
- **Phase 2**: Keycloak bearer auth, PHP proxy decision, persisted settings.
|
||||||
|
- **Phase 3**: nice-to-haves (card auto-creation on incident, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — wire up the real API
|
||||||
|
|
||||||
|
All API calls live in `oumta-dashboard.js` → `api()`. Every case currently returns a `mock…` helper; swap them for `fetch` against the backend. The URL is constructed from `state.settings.baseUrl`.
|
||||||
|
|
||||||
|
### Endpoints to wire (listed in the order the UI consumes them)
|
||||||
|
|
||||||
|
| UI call | Backend endpoint (planned) | Status in backend | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api('/api/namespaces')` | `GET /api/namespaces` | **not yet implemented** | returns the namespace tabs shown at the top. See "Open backend questions" below. |
|
||||||
|
| `api('/api/cluster/overview')` | `GET /api/cluster/overview?namespace=…` | **exists (stub)** | current stub returns zeros. Also need `namespace` query param. |
|
||||||
|
| `api('/api/workloads')` | `GET /api/workloads?namespace=…&state=…&since=…` | not yet | filters: `state`, `since` (ISO), also maybe `search`. |
|
||||||
|
| `api('/api/workloads/{ns}/{name}/timeline')` | `GET /api/workloads/{ns}/{name}/timeline` | not yet | workload-detail timeline |
|
||||||
|
| `api('/api/timeline')` | `GET /api/timeline?namespace=…` | not yet | global timeline |
|
||||||
|
|
||||||
|
### Real `fetch` implementation sketch
|
||||||
|
|
||||||
|
In `api()` replace the mock dispatch with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const url = new URL(path, state.settings.baseUrl);
|
||||||
|
if (!url.searchParams.has('namespace') && state.activeNamespace)
|
||||||
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
|
const headers = state.settings.authToken
|
||||||
|
? { Authorization: 'Bearer ' + state.settings.authToken }
|
||||||
|
: {};
|
||||||
|
const res = await fetch(url, { ...opts, headers });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
There's also a commented-out sketch inside `api()` itself — delete the mock branches once real endpoints work.
|
||||||
|
|
||||||
|
### Contract alignment — [REAL] vs [PROPOSED] fields
|
||||||
|
|
||||||
|
Mocks in `oumta-dashboard.js` are labelled inline:
|
||||||
|
|
||||||
|
- **[REAL]** — field matches the Go struct exactly (from `internal/httpapi/router.go`). Must stay byte-for-byte aligned when flipping to real.
|
||||||
|
- **[PROPOSED]** — our guess based on prose in backend `docs/README.md`. Backend author may pick different names; UI has one place per field to update, and there's a commented-out adapter slot in `api()` if several diverge.
|
||||||
|
|
||||||
|
**Overview** (`GET /api/cluster/overview`) — entirely [REAL]:
|
||||||
|
```
|
||||||
|
clusterState (string: healthy | degraded | down | unknown)
|
||||||
|
workloadsUp (int)
|
||||||
|
workloadsDown (int)
|
||||||
|
updatedAtUtc (string, RFC3339)
|
||||||
|
```
|
||||||
|
Extras shown on the Overview page (incidents count, attention list) are **derived client-side** from the workloads response — no contract addition needed from backend.
|
||||||
|
|
||||||
|
**Workloads** (`GET /api/workloads`) — [PROPOSED], derived from `docs/README.md §2`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
workloadId // synthetic handle, format "deployment/{ns}/{name}"
|
||||||
|
name // ← from doc "Name"
|
||||||
|
namespace // ← "Namespace"
|
||||||
|
state // ← "Zustand" — vocab: healthy | degraded | unavailable
|
||||||
|
replicasReady // ← "Replikas (ready/…)"
|
||||||
|
replicasDesired // ← "Replikas (…/desired)"
|
||||||
|
cpu // ← "CPU-Auslastung"
|
||||||
|
memory // ← "Speicherverbrauch"
|
||||||
|
image // ← "Aktives Docker-Image"
|
||||||
|
lastReason // ← "Letzter bekannter Grund fuer Zustandswechsel"
|
||||||
|
lastTransitionAt // ← "inkl. letzter Statuswechsel-Zeit"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline** (`GET /api/timeline`, `GET /api/workloads/{ns}/{name}/timeline`) — [PROPOSED], derived from `docs/README.md §3`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
timestamp // RFC3339
|
||||||
|
severity // info | warning | error
|
||||||
|
workloadId // same handle as above
|
||||||
|
message // short human description
|
||||||
|
reason // cause code, e.g. CrashLoopBackOff, ImagePullBackOff
|
||||||
|
kind // deployment | incident | scale — optional UI hint, drop if absent
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Namespaces** (`GET /api/namespaces`) — [PROPOSED], new endpoint:
|
||||||
|
```
|
||||||
|
items: [{ id, label, namespace }]
|
||||||
|
```
|
||||||
|
`id` could equal `namespace` if backend treats them as the same. `label` is optional display text.
|
||||||
|
|
||||||
|
If/when real shapes differ, the fix path in priority order:
|
||||||
|
1. Rename in `mockXxx()` + renderer (one place each).
|
||||||
|
2. If multiple fields diverge on one endpoint, add a mapping step inside `api()` before `return`.
|
||||||
|
3. If the shape differs fundamentally, split the renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — auth, persistence, proxy decision
|
||||||
|
|
||||||
|
### Keycloak bearer flow
|
||||||
|
|
||||||
|
- Decision pending (see user note): probably SSO-wrapped. The plugin already sends `Authorization: Bearer <state.settings.authToken>` if a token is configured; the open question is **how the token arrives**.
|
||||||
|
- Options:
|
||||||
|
- **A.** User pastes a token into the settings tab (good for POC, not for prod).
|
||||||
|
- **B.** hyprcrd itself becomes a Keycloak client, acquires a token, exposes it via `window.hyprcrd.authToken` — plugin reads that. Requires a hyprcrd-core change.
|
||||||
|
- **C.** PHP proxy (see below) attaches the token server-side; the browser never sees it.
|
||||||
|
- **Recommendation**: go with **C** once we decide on the proxy. Until then **A** is fine for dev.
|
||||||
|
|
||||||
|
### PHP proxy in hyprcrd
|
||||||
|
|
||||||
|
- Today the plugin calls the Go backend **directly from the browser**. That means:
|
||||||
|
- The Go backend must return permissive CORS (it does: `Access-Control-Allow-Origin` is wide-open in dev).
|
||||||
|
- The user's browser must reach the backend (issue if the backend lives inside a private network).
|
||||||
|
- If we add a PHP proxy (file: `public/plugins/oumta-dashboard/backend.php`), we'd:
|
||||||
|
- Route `/api/oumta/*` → forward to `baseUrl + *`.
|
||||||
|
- Store `baseUrl` + `authToken` in hyprcrd's `settings` table instead of `localStorage`.
|
||||||
|
- Get CORS for free because the request originates from hyprcrd's own origin.
|
||||||
|
- **Trigger for adding the proxy**: when we get the Keycloak spec, or when users report "dashboard only works when I'm VPN'd".
|
||||||
|
|
||||||
|
### Settings persistence
|
||||||
|
|
||||||
|
- Currently `localStorage['oumta-dashboard-settings']` — fine for a single user in one browser.
|
||||||
|
- If we want per-tenant server-side settings, add a hyprcrd `settings` key like `oumta.base_url` and expose via a small PHP endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — realtime
|
||||||
|
|
||||||
|
- `startLive()` currently just starts polling (every `settings.pollIntervalSec`).
|
||||||
|
- SSE plan (see commented-out block in `startLive()`):
|
||||||
|
- Backend exposes `GET /api/events?namespace=…` as an `EventSource`-compatible stream.
|
||||||
|
- On `onopen` → `setLive('live')`, apply incoming events live (invalidate `state.data.overview` etc. or surgically patch).
|
||||||
|
- On `onerror` → fall back to polling, `setLive('polling')` with a hint.
|
||||||
|
- SSE event shape proposal (to negotiate with backend): `{ type: 'workload.state', workloadId, state, reason, timestamp }`, etc. Parking this until the backend is ready to talk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3+ — nice to haves (not in scope yet)
|
||||||
|
|
||||||
|
From the original questions catalog we parked:
|
||||||
|
- Timezone handling: UTC vs. local browser time (probably just use local + tooltip with UTC).
|
||||||
|
- Plugin settings in hyprcrd's settings-tab vs. per-user vs. shared across tenants.
|
||||||
|
- Auto-create a hyprcrd card when an incident is opened (tie the two systems together).
|
||||||
|
- Filter persistence in URL hash so links are shareable.
|
||||||
|
- Light/dark accent variants — currently inherits theme variables, should just work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open backend questions (to sync with the Go team)
|
||||||
|
|
||||||
|
1. **Namespace list endpoint** — UI needs an endpoint to populate the namespace tabs. The doc says "Namespace-Auswahl per Menü" and mentions env-var config on the backend. What's the endpoint? `GET /api/namespaces`? If not, how does the UI discover them?
|
||||||
|
2. **Response envelope** — the existing `overview` endpoint returns a flat object. Lists should probably be `{ items: [...] }` (consistent with our renderers) or `[]` (then we'll adapt). Pick one and stick with it across all list endpoints.
|
||||||
|
3. **Cluster state values** — our Overview card colour-codes on `healthy | degraded | down | unknown`. Backend currently returns `"unknown"` as a literal. Confirm the vocabulary.
|
||||||
|
4. **Workload state values** — we colour-code on `healthy | degraded | unavailable`. The doc uses these but the old .NET API also had `withRestarts` / `withWarnings` as summary counters. Only the three we use? Or more?
|
||||||
|
5. **Workload ID shape** — we use `deployment/{namespace}/{name}` as a synthetic id (so workloads can be linked from the timeline). Does the backend emit that exact form, or just `{namespace}/{name}`? We can adapt either way.
|
||||||
|
6. **Timeline pagination** — limit? cursor? since? The .NET version had all three; the Go doc mentions only filters. Enough for MVP?
|
||||||
|
7. **Realtime channel** — path + event format. We've got a plan above; needs the backend team's blessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal follow-ups
|
||||||
|
|
||||||
|
- `renderWorkloads` is called on every keystroke in the filter — fine up to ~1k rows, might need debouncing above that.
|
||||||
|
- `refresh()` tears down the scoped-workload timeline whenever it fires. For Phase 1 that's acceptable; once real data lands, consider NOT overwriting `state.data.timeline` when `state.data.scopeWorkload` is active.
|
||||||
|
- `bootstrap()` only runs on first show. If the user changes the API base URL in settings while the overlay is open, we re-start live mode but don't re-fetch namespaces. Add a "reset & reload" action if that matters.
|
||||||
|
- Namespace tab bar shows `namespace.label`; we currently fake it. Once real, consider showing the full k8s namespace if `label` and `namespace` differ.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/* ── Namespace tabs ── */
|
||||||
|
.oumta-ns-tab {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.oumta-ns-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-ns-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* ── View tabs ── */
|
||||||
|
.oumta-view-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-view-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-view-tab.active { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.oumta-view.hidden { display: none; }
|
||||||
|
.oumta-view { padding: 20px 24px; }
|
||||||
|
|
||||||
|
/* ── Overview cards ── */
|
||||||
|
.oumta-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.oumta-card {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.oumta-card-wide { grid-column: span 4; }
|
||||||
|
.oumta-card-label {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-card-value {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.oumta-card-value-sm { font-size: 14px; font-weight: normal; }
|
||||||
|
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-danger { color: var(--t-danger); }
|
||||||
|
|
||||||
|
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
|
||||||
|
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
|
||||||
|
.oumta-cluster-status[data-state="down"] .oumta-card-value { color: var(--t-danger); }
|
||||||
|
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
|
||||||
|
|
||||||
|
/* ── Attention list ── */
|
||||||
|
.oumta-section { margin-top: 8px; }
|
||||||
|
.oumta-section-title {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-attention-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.oumta-attention-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-attention-row:hover { border-color: var(--t-accent); }
|
||||||
|
.oumta-attention-name { flex: 1; color: var(--t-bright); font-size: 13px; }
|
||||||
|
.oumta-attention-ns { color: var(--t-dim); font-size: 11px; font-family: var(--font-content, monospace); }
|
||||||
|
.oumta-attention-reason { color: var(--t-muted); font-size: 11px; font-style: italic; }
|
||||||
|
.oumta-attention-state {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.oumta-attention-state[data-state="degraded"] { color: #fb923c; border-color: #fb923c; }
|
||||||
|
.oumta-attention-state[data-state="unavailable"] { color: var(--t-danger); border-color: var(--t-danger); }
|
||||||
|
|
||||||
|
.oumta-empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workload toolbar + table ── */
|
||||||
|
.oumta-toolbar {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.oumta-input {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.oumta-input:focus { border-color: var(--t-accent); }
|
||||||
|
#oumta-wl-search { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
.oumta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.oumta-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.oumta-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
||||||
|
.oumta-table tbody tr:hover { background: var(--t-bg); }
|
||||||
|
.oumta-table .wl-state-healthy { color: #4ade80; }
|
||||||
|
.oumta-table .wl-state-degraded { color: #fb923c; }
|
||||||
|
.oumta-table .wl-state-unavailable{ color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Timeline ── */
|
||||||
|
.oumta-timeline-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
|
.oumta-timeline-body {
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
border-left: 2px solid var(--t-border);
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 80px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.oumta-timeline-time { font-family: var(--font-content, monospace); font-size: 11px; color: var(--t-dim); }
|
||||||
|
.oumta-timeline-severity {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.oumta-timeline-severity[data-sev="info"] { color: var(--t-accent); }
|
||||||
|
.oumta-timeline-severity[data-sev="warning"] { color: #fb923c; }
|
||||||
|
.oumta-timeline-severity[data-sev="error"] { color: var(--t-danger); }
|
||||||
|
.oumta-timeline-message { font-size: 12px; color: var(--t-bright); }
|
||||||
|
.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Live indicator ── */
|
||||||
|
.oumta-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--t-dim);
|
||||||
|
display: inline-block;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #fb923c; }
|
||||||
|
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Settings tab ── */
|
||||||
|
.oumta-settings-field {
|
||||||
|
display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.oumta-settings-field label {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px; color: var(--t-dim); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.oumta-settings-field input {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.oumta-settings-field input:focus { border-color: var(--t-accent); }
|
||||||
|
.oumta-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
|
||||||
|
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
||||||
|
<i class="ph ph-gauge text-t-accent text-base"></i>
|
||||||
|
<h2 class="font-pixel text-xs text-t-accent">oumta_dashboard</h2>
|
||||||
|
<span id="oumta-generated-at" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="oumta-refresh" 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" data-tip="Refresh now">
|
||||||
|
<i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button id="oumta-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>
|
||||||
|
|
||||||
|
<!-- Namespace tabs -->
|
||||||
|
<div id="oumta-ns-tabs" class="flex items-center gap-0 border-b border-t-border shrink-0 px-5"></div>
|
||||||
|
|
||||||
|
<!-- View tabs (Overview / Workloads / Timeline) -->
|
||||||
|
<div class="flex items-center gap-4 border-b border-t-border shrink-0 px-5 py-2 bg-t-bg">
|
||||||
|
<button class="oumta-view-tab active" data-view="overview"><i class="ph ph-squares-four text-sm"></i> Overview</button>
|
||||||
|
<button class="oumta-view-tab" data-view="workloads"><i class="ph ph-list text-sm"></i> Workloads</button>
|
||||||
|
<button class="oumta-view-tab" data-view="timeline"><i class="ph ph-clock-counter-clockwise text-sm"></i> Timeline</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<span id="oumta-live-indicator" class="flex items-center gap-1 font-pixel text-[9px] text-t-dim" data-tip="Realtime status">
|
||||||
|
<span class="oumta-dot"></span><span id="oumta-live-label">connecting…</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-auto" id="oumta-body">
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="oumta-view" data-view="overview">
|
||||||
|
<div class="oumta-overview-grid">
|
||||||
|
<div class="oumta-card oumta-cluster-status" id="oumta-cluster-card">
|
||||||
|
<div class="oumta-card-label">Cluster state</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-cluster-state">—</div>
|
||||||
|
<div class="oumta-card-sub" id="oumta-cluster-updated">no data</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Workloads healthy</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-workloads-up">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Workloads down</div>
|
||||||
|
<div class="oumta-card-value oumta-danger" id="oumta-workloads-down">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card">
|
||||||
|
<div class="oumta-card-label">Open incidents</div>
|
||||||
|
<div class="oumta-card-value" id="oumta-incidents">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-card oumta-card-wide">
|
||||||
|
<div class="oumta-card-label">Last successful import</div>
|
||||||
|
<div class="oumta-card-value oumta-card-value-sm" id="oumta-last-import">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-section">
|
||||||
|
<div class="oumta-section-title">Workloads needing attention</div>
|
||||||
|
<div id="oumta-attention-list" class="oumta-attention-list">
|
||||||
|
<div class="oumta-empty">Nothing currently flagged.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Workloads -->
|
||||||
|
<section class="oumta-view hidden" data-view="workloads">
|
||||||
|
<div class="oumta-toolbar">
|
||||||
|
<input id="oumta-wl-search" type="text" placeholder="Filter by name…" class="oumta-input">
|
||||||
|
<select id="oumta-wl-status" class="oumta-input">
|
||||||
|
<option value="">All states</option>
|
||||||
|
<option value="healthy">healthy</option>
|
||||||
|
<option value="degraded">degraded</option>
|
||||||
|
<option value="unavailable">unavailable</option>
|
||||||
|
</select>
|
||||||
|
<select id="oumta-wl-timerange" class="oumta-input">
|
||||||
|
<option value="1h">last 1h</option>
|
||||||
|
<option value="24h" selected>last 24h</option>
|
||||||
|
<option value="7d">last 7d</option>
|
||||||
|
<option value="">all</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table class="oumta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Replicas</th>
|
||||||
|
<th>CPU</th>
|
||||||
|
<th>Memory</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Last reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="oumta-wl-body">
|
||||||
|
<tr><td colspan="8" class="oumta-empty">No workloads.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Timeline (workload-detail opens here as well) -->
|
||||||
|
<section class="oumta-view hidden" data-view="timeline">
|
||||||
|
<div class="oumta-timeline-header">
|
||||||
|
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
|
||||||
|
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
|
||||||
|
</div>
|
||||||
|
<div id="oumta-timeline-body" class="oumta-timeline-body">
|
||||||
|
<div class="oumta-empty">No events.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
// ── Oumta Dashboard plugin ──
|
||||||
|
//
|
||||||
|
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
||||||
|
// Currently ALL data is mocked so we can iterate on layout without waiting
|
||||||
|
// for the backend. See TODO.md for every call that must later become a real
|
||||||
|
// fetch against the backend.
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ── Settings / persisted state ──
|
||||||
|
//
|
||||||
|
// We use localStorage for now. Later we might persist via a hyprcrd setting
|
||||||
|
// or a plugin-level table — see TODO.md.
|
||||||
|
|
||||||
|
const LS_KEY = 'oumta-dashboard-settings';
|
||||||
|
const state = {
|
||||||
|
settings: {
|
||||||
|
baseUrl: '', // e.g. https://dashboard.oumta.internal
|
||||||
|
authToken: '', // Keycloak bearer; empty = no auth yet
|
||||||
|
pollIntervalSec: 15,
|
||||||
|
},
|
||||||
|
namespaces: [], // [{ id, label, namespace }]
|
||||||
|
activeNamespace: null,
|
||||||
|
activeView: 'overview',
|
||||||
|
liveMode: 'polling', // 'live' | 'polling' | 'error' | 'off'
|
||||||
|
pollHandle: null,
|
||||||
|
sseHandle: null,
|
||||||
|
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
if (raw) Object.assign(state.settings, JSON.parse(raw));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
function saveSettings() {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(state.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central API surface ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every backend call routes through `api()`. Right now each case returns
|
||||||
|
// mock data. When the backend endpoints are ready, swap the `return MOCK…`
|
||||||
|
// lines for real `fetch(…)` calls. The UI code above this layer should
|
||||||
|
// not need to change.
|
||||||
|
//
|
||||||
|
// Field naming legend for the mocks below:
|
||||||
|
// [REAL] = already defined in Go code (internal/httpapi/router.go).
|
||||||
|
// Must match byte-for-byte when flipping to real fetch.
|
||||||
|
// [PROPOSED] = our guess based on docs/README.md prose. Backend author
|
||||||
|
// is free to rename — when they do, adjust the renderer
|
||||||
|
// (there's only one place per field) or add a small adapter
|
||||||
|
// inside api() that normalises the response.
|
||||||
|
|
||||||
|
const MOCK_NAMESPACES = [
|
||||||
|
{ id: 'oumta-dev', label: 'oumta-dev', namespace: 'oumta-dev' },
|
||||||
|
{ id: 'oumta-beta', label: 'oumta-beta', namespace: 'oumta-beta' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Overview mock — shape matches the existing Go struct `clusterOverviewResponse`
|
||||||
|
// byte-for-byte (see cmd/api/main.go → internal/httpapi/router.go).
|
||||||
|
// [REAL] clusterState, workloadsUp, workloadsDown, updatedAtUtc
|
||||||
|
// Anything else the overview page shows (incidents count, attention list) is
|
||||||
|
// DERIVED client-side from the workloads response — see renderOverview().
|
||||||
|
function mockOverview(ns) {
|
||||||
|
const wl = mockWorkloads(ns);
|
||||||
|
const up = wl.filter(w => w.state === 'healthy').length;
|
||||||
|
const down = wl.filter(w => w.state === 'unavailable').length;
|
||||||
|
const degraded = wl.filter(w => w.state === 'degraded').length;
|
||||||
|
const clusterState = down > 0 ? 'down' : (degraded > 0 ? 'degraded' : 'healthy');
|
||||||
|
return {
|
||||||
|
clusterState,
|
||||||
|
workloadsUp: up,
|
||||||
|
workloadsDown: down,
|
||||||
|
updatedAtUtc: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workloads mock — [PROPOSED] shape. Fields chosen from docs/README.md §2:
|
||||||
|
// Name, Namespace, Zustand, Replikas (ready/desired), CPU, Speicher,
|
||||||
|
// Image, Letzter Grund für Zustandswechsel.
|
||||||
|
// `workloadId` is our synthetic stable handle (kind/namespace/name) so the
|
||||||
|
// timeline can be linked back. If the backend emits a different handle
|
||||||
|
// format we'll adjust the link-builders.
|
||||||
|
function mockWorkloads(ns) {
|
||||||
|
const id = name => `deployment/${ns}/${name}`;
|
||||||
|
return [
|
||||||
|
{ workloadId: id('api'), name: 'api', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '420m', memory: '512Mi', image: 'oumta/api:1.4.2', lastReason: 'CrashLoopBackOff', lastTransitionAt: minutesAgo(12) },
|
||||||
|
{ workloadId: id('api-canary'), name: 'api-canary', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '140m', memory: '310Mi', image: 'oumta/api:1.4.3-rc2', lastReason: '—', lastTransitionAt: minutesAgo(180) },
|
||||||
|
{ workloadId: id('web'), name: 'web', namespace: ns, state: 'healthy', replicasReady: 2, replicasDesired: 2, cpu: '95m', memory: '256Mi', image: 'oumta/web:2.1.0', lastReason: '—', lastTransitionAt: minutesAgo(720) },
|
||||||
|
{ workloadId: id('worker'), name: 'worker', namespace: ns, state: 'healthy', replicasReady: 4, replicasDesired: 4, cpu: '820m', memory: '1Gi', image: 'oumta/worker:1.4.2', lastReason: '—', lastTransitionAt: minutesAgo(121) },
|
||||||
|
{ workloadId: id('ingest'), name: 'ingest', namespace: ns, state: 'unavailable', replicasReady: 0, replicasDesired: 1, cpu: '—', memory: '—', image: 'oumta/ingest:3.0.0', lastReason: 'ImagePullBackOff', lastTransitionAt: minutesAgo(3) },
|
||||||
|
{ workloadId: id('scheduler'), name: 'scheduler', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '55m', memory: '120Mi', image: 'oumta/scheduler:0.9.1', lastReason: '—', lastTransitionAt: minutesAgo(2880)},
|
||||||
|
{ workloadId: id('postgres'), name: 'postgres', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '180m', memory: '768Mi', image: 'postgres:16.2', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
|
||||||
|
{ workloadId: id('redis'), name: 'redis', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '25m', memory: '80Mi', image: 'redis:7.2-alpine', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
|
||||||
|
{ workloadId: id('nats'), name: 'nats', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '60m', memory: '200Mi', image: 'nats:2.10.14', lastReason: 'Readiness probe failed', lastTransitionAt: minutesAgo(45) },
|
||||||
|
{ workloadId: id('reports'), name: 'reports', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '75m', memory: '180Mi', image: 'oumta/reports:1.2.0', lastReason: '—', lastTransitionAt: minutesAgo(600) },
|
||||||
|
{ workloadId: id('notifier'), name: 'notifier', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '30m', memory: '96Mi', image: 'oumta/notifier:0.5.3', lastReason: '—', lastTransitionAt: minutesAgo(1500)},
|
||||||
|
{ workloadId: id('migrations'), name: 'migrations', namespace: ns, state: 'healthy', replicasReady: 0, replicasDesired: 0, cpu: '—', memory: '—', image: 'oumta/migrations:1.4.2', lastReason: 'Completed', lastTransitionAt: minutesAgo(300) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline mock — [PROPOSED] shape.
|
||||||
|
// Fields inferred from docs §3 (Detail page) & §4 (filters): timestamp,
|
||||||
|
// severity, workloadId, message, reason. `kind` is a derived hint for
|
||||||
|
// rendering (deployment vs. incident vs. scale etc.) and can be dropped
|
||||||
|
// if the backend doesn't provide it.
|
||||||
|
function mockTimeline(ns, workloadId = null) {
|
||||||
|
const id = name => `deployment/${ns}/${name}`;
|
||||||
|
const all = [
|
||||||
|
{ timestamp: minutesAgo(3), severity: 'error', workloadId: id('ingest'), message: 'Pod ingest-6d8bf4 entered ImagePullBackOff', reason: 'ImagePullBackOff', kind: 'incident' },
|
||||||
|
{ timestamp: minutesAgo(8), severity: 'warning', workloadId: id('nats'), message: 'Replica 3/3 readiness probe failed', reason: 'ProbeFailed', kind: 'incident' },
|
||||||
|
{ timestamp: minutesAgo(12), severity: 'warning', workloadId: id('api'), message: 'Pod api-7c9ff6 restarted (3rd time in 10m)', reason: 'CrashLoopBackOff', kind: 'incident' },
|
||||||
|
{ timestamp: minutesAgo(26), severity: 'info', workloadId: id('api'), message: 'HorizontalPodAutoscaler targeted 3 replicas', reason: 'HPA', kind: 'scale' },
|
||||||
|
{ timestamp: minutesAgo(48), severity: 'info', workloadId: id('web'), message: 'Rollout completed to revision 17', reason: 'DeploymentRolled', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(58), severity: 'info', workloadId: id('web'), message: 'Rollout started: oumta/web:2.1.0', reason: 'DeploymentStarted', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(121), severity: 'info', workloadId: id('worker'), message: 'Scaled up from 2 to 4 replicas', reason: 'HPA', kind: 'scale' },
|
||||||
|
{ timestamp: minutesAgo(180), severity: 'info', workloadId: id('api-canary'), message: 'Canary became ready', reason: 'CanaryReady', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(240), severity: 'info', workloadId: id('api'), message: 'Rollout started: oumta/api:1.4.2', reason: 'DeploymentStarted', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(300), severity: 'info', workloadId: id('migrations'), message: 'Job completed successfully in 1m42s', reason: 'JobSucceeded', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(720), severity: 'info', workloadId: id('reports'), message: 'Rolled out revision 9', reason: 'DeploymentRolled', kind: 'deployment' },
|
||||||
|
{ timestamp: minutesAgo(1500), severity: 'warning', workloadId: id('ingest'), message: 'OOMKilled: container exceeded 256Mi limit', reason: 'OOMKilled', kind: 'incident' },
|
||||||
|
{ timestamp: minutesAgo(2880), severity: 'info', workloadId: id('scheduler'), message: 'Initial rollout: oumta/scheduler:0.9.1', reason: 'DeploymentStarted', kind: 'deployment' },
|
||||||
|
];
|
||||||
|
return workloadId ? all.filter(e => e.workloadId === workloadId) : all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesAgo(m) { return new Date(Date.now() - m * 60_000).toISOString(); }
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
// TODO(api): when backend is ready, build a real fetch here.
|
||||||
|
// const url = new URL(path, state.settings.baseUrl);
|
||||||
|
// const headers = state.settings.authToken
|
||||||
|
// ? { Authorization: 'Bearer ' + state.settings.authToken } : {};
|
||||||
|
// const res = await fetch(url, { ...opts, headers });
|
||||||
|
// if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
// return res.json();
|
||||||
|
|
||||||
|
// Mock dispatch — keep in sync with backend `docs/README.md`.
|
||||||
|
const ns = state.activeNamespace?.namespace || '';
|
||||||
|
if (path === '/api/namespaces') return { items: MOCK_NAMESPACES }; // TODO(api)
|
||||||
|
if (path === '/api/cluster/overview') return mockOverview(ns); // TODO(api): GET /api/cluster/overview?namespace=
|
||||||
|
if (path === '/api/workloads') return { items: mockWorkloads(ns) }; // TODO(api): GET /api/workloads?namespace=&state=&since=
|
||||||
|
if (path.startsWith('/api/workloads/') && path.endsWith('/timeline')) {
|
||||||
|
// /api/workloads/{namespace}/{name}/timeline
|
||||||
|
const parts = path.replace('/api/workloads/', '').replace('/timeline', '').split('/');
|
||||||
|
const workloadId = `deployment/${parts[0]}/${parts[1]}`;
|
||||||
|
return { items: mockTimeline(ns, workloadId) }; // TODO(api)
|
||||||
|
}
|
||||||
|
if (path === '/api/timeline') return { items: mockTimeline(ns) }; // TODO(api): GET /api/timeline?namespace=
|
||||||
|
throw new Error('unknown api path: ' + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fullscreen overlay show/hide ─────────────────────────────────
|
||||||
|
|
||||||
|
const overlay = $('oumta-overlay');
|
||||||
|
|
||||||
|
function isOpen() {
|
||||||
|
return overlay?.classList.contains('open');
|
||||||
|
}
|
||||||
|
function show() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.add('open');
|
||||||
|
if (!state.namespaces.length) bootstrap();
|
||||||
|
else {
|
||||||
|
refresh();
|
||||||
|
startLive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
stopLive();
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
isOpen() ? hide() : show();
|
||||||
|
}
|
||||||
|
window._oumtaToggle = toggle;
|
||||||
|
|
||||||
|
// ── Bootstrap: load namespaces, then first refresh ─────────────
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const res = await api('/api/namespaces');
|
||||||
|
state.namespaces = res.items || [];
|
||||||
|
state.activeNamespace = state.namespaces[0] || null;
|
||||||
|
renderNamespaceTabs();
|
||||||
|
await refresh();
|
||||||
|
startLive();
|
||||||
|
} catch (e) {
|
||||||
|
setLive('error', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNamespaceTabs() {
|
||||||
|
const host = $('oumta-ns-tabs');
|
||||||
|
if (!host) return;
|
||||||
|
host.innerHTML = state.namespaces.map(n => `
|
||||||
|
<button class="oumta-ns-tab ${state.activeNamespace?.id === n.id ? 'active' : ''}" data-ns="${n.id}">
|
||||||
|
${n.label}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
host.querySelectorAll('.oumta-ns-tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const ns = state.namespaces.find(x => x.id === btn.dataset.ns);
|
||||||
|
if (ns && ns.id !== state.activeNamespace?.id) {
|
||||||
|
state.activeNamespace = ns;
|
||||||
|
renderNamespaceTabs();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const tab = e.target.closest('.oumta-view-tab');
|
||||||
|
if (!tab || !overlay.contains(tab)) return;
|
||||||
|
switchView(tab.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
state.activeView = view;
|
||||||
|
document.querySelectorAll('.oumta-view-tab').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.view === view));
|
||||||
|
document.querySelectorAll('.oumta-view').forEach(s =>
|
||||||
|
s.classList.toggle('hidden', s.dataset.view !== view));
|
||||||
|
// Reset workload-scoped timeline when leaving timeline view
|
||||||
|
if (view !== 'timeline') state.data.scopeWorkload = null;
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh all data for the active namespace ──────────────────
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!state.activeNamespace) return;
|
||||||
|
try {
|
||||||
|
const [ov, wl, tl] = await Promise.all([
|
||||||
|
api('/api/cluster/overview'),
|
||||||
|
api('/api/workloads'),
|
||||||
|
api('/api/timeline'),
|
||||||
|
]);
|
||||||
|
state.data.overview = ov;
|
||||||
|
state.data.workloads = wl.items || [];
|
||||||
|
state.data.timeline = tl.items || [];
|
||||||
|
renderAll();
|
||||||
|
} catch (e) {
|
||||||
|
setLive('error', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Renderers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderOverview();
|
||||||
|
renderWorkloads();
|
||||||
|
renderTimeline();
|
||||||
|
const genAt = state.data.overview?.updatedAtUtc;
|
||||||
|
const el = $('oumta-generated-at');
|
||||||
|
if (el && genAt) el.textContent = 'last import: ' + formatTime(genAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview() {
|
||||||
|
const o = state.data.overview;
|
||||||
|
const wl = state.data.workloads || [];
|
||||||
|
if (!o) return;
|
||||||
|
|
||||||
|
// Values that come directly from the Go overview endpoint [REAL].
|
||||||
|
$('oumta-cluster-card').dataset.state = o.clusterState || 'unknown';
|
||||||
|
$('oumta-cluster-state').textContent = o.clusterState || 'unknown';
|
||||||
|
$('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data';
|
||||||
|
$('oumta-workloads-up').textContent = o.workloadsUp ?? '—';
|
||||||
|
$('oumta-workloads-down').textContent = o.workloadsDown ?? '—';
|
||||||
|
$('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—';
|
||||||
|
|
||||||
|
// Values derived client-side from the workloads list — the overview
|
||||||
|
// endpoint doesn't carry these today. If the backend later adds
|
||||||
|
// `incidents` / `attention` fields, prefer those over the derivation.
|
||||||
|
const attention = wl.filter(w => w.state !== 'healthy')
|
||||||
|
.sort((a, b) => rankState(a.state) - rankState(b.state));
|
||||||
|
$('oumta-incidents').textContent = attention.length || '—';
|
||||||
|
|
||||||
|
const list = $('oumta-attention-list');
|
||||||
|
if (!attention.length) {
|
||||||
|
list.innerHTML = '<div class="oumta-empty">Nothing currently flagged.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = attention.map(a => `
|
||||||
|
<div class="oumta-attention-row" data-wlid="${escapeAttr(a.workloadId)}">
|
||||||
|
<span class="oumta-attention-state" data-state="${a.state}">${a.state}</span>
|
||||||
|
<span class="oumta-attention-name">${escapeHtml(a.name)}</span>
|
||||||
|
<span class="oumta-attention-ns">${escapeHtml(a.namespace)}</span>
|
||||||
|
<span class="oumta-attention-reason">${escapeHtml(a.lastReason || '')}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
list.querySelectorAll('.oumta-attention-row').forEach(row => {
|
||||||
|
row.addEventListener('click', () => openWorkload(row.dataset.wlid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity order for the attention list: worst first.
|
||||||
|
function rankState(s) {
|
||||||
|
return { unavailable: 0, degraded: 1, healthy: 2 }[s] ?? 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkloads() {
|
||||||
|
const search = ($('oumta-wl-search').value || '').toLowerCase();
|
||||||
|
const filterState = $('oumta-wl-status').value;
|
||||||
|
const body = $('oumta-wl-body');
|
||||||
|
const rows = (state.data.workloads || []).filter(w => {
|
||||||
|
if (search && !w.name.toLowerCase().includes(search)) return false;
|
||||||
|
if (filterState && w.state !== filterState) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!rows.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="8" class="oumta-empty">No workloads match.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = rows.map(w => `
|
||||||
|
<tr data-wlid="${escapeAttr(w.workloadId)}">
|
||||||
|
<td>${escapeHtml(w.name)}</td>
|
||||||
|
<td>${escapeHtml(w.namespace)}</td>
|
||||||
|
<td class="wl-state-${w.state}">${w.state}</td>
|
||||||
|
<td>${w.replicasReady}/${w.replicasDesired}</td>
|
||||||
|
<td>${escapeHtml(w.cpu)}</td>
|
||||||
|
<td>${escapeHtml(w.memory)}</td>
|
||||||
|
<td>${escapeHtml(w.image)}</td>
|
||||||
|
<td>${escapeHtml(w.lastReason)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
body.querySelectorAll('tr').forEach(tr => {
|
||||||
|
tr.addEventListener('click', () => openWorkload(tr.dataset.wlid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline() {
|
||||||
|
const scope = state.data.scopeWorkload;
|
||||||
|
const scopeEl = $('oumta-timeline-scope');
|
||||||
|
const backEl = $('oumta-timeline-back');
|
||||||
|
if (scope) {
|
||||||
|
scopeEl.textContent = 'events for ' + scope;
|
||||||
|
backEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
scopeEl.textContent = 'all events';
|
||||||
|
backEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
const events = scope
|
||||||
|
? (state.data.timeline.filter(e => e.workloadId === scope))
|
||||||
|
: state.data.timeline;
|
||||||
|
const body = $('oumta-timeline-body');
|
||||||
|
if (!events.length) {
|
||||||
|
body.innerHTML = '<div class="oumta-empty">No events.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = events.map(e => `
|
||||||
|
<div class="oumta-timeline-entry">
|
||||||
|
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
|
||||||
|
<div class="oumta-timeline-severity" data-sev="${e.severity}">${e.severity}</div>
|
||||||
|
<div>
|
||||||
|
<div class="oumta-timeline-message">${escapeHtml(e.message)}</div>
|
||||||
|
<div class="oumta-timeline-meta">${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workload → detail-as-timeline navigation ──────────────────
|
||||||
|
|
||||||
|
async function openWorkload(workloadId) {
|
||||||
|
state.data.scopeWorkload = workloadId;
|
||||||
|
const [nsSlug, name] = workloadId.replace('deployment/', '').split('/');
|
||||||
|
// Pull workload-scoped timeline — this REPLACES the generic one for rendering.
|
||||||
|
try {
|
||||||
|
const res = await api(`/api/workloads/${nsSlug}/${name}/timeline`);
|
||||||
|
state.data.timeline = res.items || [];
|
||||||
|
} catch (e) { setLive('error', e.message); }
|
||||||
|
switchView('timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('oumta-timeline-back')?.addEventListener('click', () => {
|
||||||
|
state.data.scopeWorkload = null;
|
||||||
|
// Restore the global timeline
|
||||||
|
api('/api/timeline').then(r => {
|
||||||
|
state.data.timeline = r.items || [];
|
||||||
|
renderTimeline();
|
||||||
|
}).catch(e => setLive('error', e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Realtime (SSE) with polling fallback ──────────────────────
|
||||||
|
//
|
||||||
|
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
|
||||||
|
// not implemented yet, so we go straight to polling. When SSE is available
|
||||||
|
// we can try to connect and fall back on `error`.
|
||||||
|
|
||||||
|
function startLive() {
|
||||||
|
stopLive();
|
||||||
|
// TODO(api): attempt SSE first:
|
||||||
|
// state.sseHandle = new EventSource(url);
|
||||||
|
// state.sseHandle.onmessage = ev => applyLiveEvent(JSON.parse(ev.data));
|
||||||
|
// state.sseHandle.onerror = () => { stopLive(); startPolling(); setLive('polling'); };
|
||||||
|
// state.sseHandle.onopen = () => setLive('live');
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
function stopLive() {
|
||||||
|
if (state.pollHandle) { clearInterval(state.pollHandle); state.pollHandle = null; }
|
||||||
|
if (state.sseHandle) { state.sseHandle.close(); state.sseHandle = null; }
|
||||||
|
setLive('off');
|
||||||
|
}
|
||||||
|
function startPolling() {
|
||||||
|
const ms = (state.settings.pollIntervalSec || 15) * 1000;
|
||||||
|
state.pollHandle = setInterval(refresh, ms);
|
||||||
|
setLive('polling');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLive(mode, detail = '') {
|
||||||
|
state.liveMode = mode;
|
||||||
|
const el = $('oumta-live-indicator');
|
||||||
|
const lbl = $('oumta-live-label');
|
||||||
|
if (el) el.dataset.state = mode;
|
||||||
|
if (lbl) {
|
||||||
|
lbl.textContent = ({
|
||||||
|
live: 'live',
|
||||||
|
polling: 'polling',
|
||||||
|
error: 'error' + (detail ? ' — ' + detail : ''),
|
||||||
|
off: 'off',
|
||||||
|
})[mode] || mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header buttons ───────────────────────────────────────────
|
||||||
|
|
||||||
|
$('oumta-close')?.addEventListener('click', hide);
|
||||||
|
$('oumta-refresh')?.addEventListener('click', refresh);
|
||||||
|
$('oumta-wl-search')?.addEventListener('input', renderWorkloads);
|
||||||
|
$('oumta-wl-status')?.addEventListener('change', renderWorkloads);
|
||||||
|
$('oumta-wl-timerange')?.addEventListener('change', () => {
|
||||||
|
// TODO(api): forward to /api/workloads?since=<ISO> once the backend accepts it.
|
||||||
|
renderWorkloads();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && isOpen()) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
// ── Inject header button into hyprcrd top bar ────────────────
|
||||||
|
|
||||||
|
function injectHeaderButton() {
|
||||||
|
const tasksBtn = document.getElementById('tasks-btn');
|
||||||
|
if (!tasksBtn || document.getElementById('oumta-btn')) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'oumta-btn';
|
||||||
|
btn.className = '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';
|
||||||
|
btn.innerHTML = '<i class="ph ph-gauge text-sm"></i><span class="font-pixel text-[10px]">Oumta</span>';
|
||||||
|
btn.addEventListener('click', toggle);
|
||||||
|
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectHeaderButton);
|
||||||
|
} else {
|
||||||
|
injectHeaderButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings tab in hyprcrd settings ─────────────────────────
|
||||||
|
|
||||||
|
function renderSettingsPanel(panelEl) {
|
||||||
|
panelEl.innerHTML = `
|
||||||
|
<div class="oumta-settings-field">
|
||||||
|
<label for="oumta-set-baseurl">API Base URL</label>
|
||||||
|
<input type="text" id="oumta-set-baseurl" placeholder="http://localhost:8080">
|
||||||
|
<div class="oumta-settings-help">Where the Oumta Go backend is reachable from this browser. No trailing slash.</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-settings-field">
|
||||||
|
<label for="oumta-set-token">Auth token (optional)</label>
|
||||||
|
<input type="text" id="oumta-set-token" placeholder="Bearer token from Keycloak">
|
||||||
|
<div class="oumta-settings-help">Will be sent as <code>Authorization: Bearer <token></code>. Leave empty if the API is unauthenticated.</div>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-settings-field">
|
||||||
|
<label for="oumta-set-poll">Poll interval (seconds)</label>
|
||||||
|
<input type="number" id="oumta-set-poll" min="5" max="300">
|
||||||
|
<div class="oumta-settings-help">Used as a fallback when SSE is unavailable.</div>
|
||||||
|
</div>
|
||||||
|
<button id="oumta-set-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors">
|
||||||
|
<i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span>
|
||||||
|
</button>
|
||||||
|
<span id="oumta-set-status" class="font-pixel text-[10px] text-t-dim ml-3"></span>
|
||||||
|
`;
|
||||||
|
$('oumta-set-baseurl').value = state.settings.baseUrl || '';
|
||||||
|
$('oumta-set-token').value = state.settings.authToken || '';
|
||||||
|
$('oumta-set-poll').value = state.settings.pollIntervalSec || 15;
|
||||||
|
$('oumta-set-save').addEventListener('click', () => {
|
||||||
|
state.settings.baseUrl = $('oumta-set-baseurl').value.trim();
|
||||||
|
state.settings.authToken = $('oumta-set-token').value.trim();
|
||||||
|
state.settings.pollIntervalSec = Math.max(5, parseInt($('oumta-set-poll').value) || 15);
|
||||||
|
saveSettings();
|
||||||
|
const s = $('oumta-set-status');
|
||||||
|
s.textContent = 'saved';
|
||||||
|
setTimeout(() => { s.textContent = ''; }, 1500);
|
||||||
|
// If the overlay is open, restart polling with new interval.
|
||||||
|
if (isOpen()) { stopLive(); startLive(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function escapeAttr(s) { return escapeHtml(s); }
|
||||||
|
function formatTime(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
h?.settings?.registerTab?.({
|
||||||
|
id: 'oumta-dashboard',
|
||||||
|
label: 'oumta',
|
||||||
|
icon: 'ph-gauge',
|
||||||
|
onShow: renderSettingsPanel,
|
||||||
|
});
|
||||||
|
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "oumta-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Oumta cluster dashboard — overview, workloads, timeline.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["oumta-dashboard.js"],
|
||||||
|
"css": ["oumta-dashboard.css"],
|
||||||
|
"html": ["oumta-dashboard.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
<span class="font-pixel text-[10px]">image:</span>
|
<span class="font-pixel text-[10px]">image:</span>
|
||||||
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
||||||
</label>
|
</label>
|
||||||
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" data-tip="pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
||||||
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" data-tip="upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
||||||
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" data-tip="delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function renderImageSlide(slide) {
|
|||||||
const rects = slide.hotspots.map((h, i) => {
|
const rects = slide.hotspots.map((h, i) => {
|
||||||
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
||||||
const label = resolveTargetLabel(h.target) || h.target || '';
|
const label = resolveTargetLabel(h.target) || h.target || '';
|
||||||
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" title="${escHtml(label)}"></div>`;
|
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" data-tip="${escHtml(label)}"></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
viewerSlide.innerHTML = `
|
viewerSlide.innerHTML = `
|
||||||
<div class="slide-image-wrap">
|
<div class="slide-image-wrap">
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# terminal plugin
|
||||||
|
|
||||||
|
In-app command line for controlling hyprcrd with text commands.
|
||||||
|
|
||||||
|
## Toggle
|
||||||
|
|
||||||
|
**Shift+T** opens/closes the terminal. **ESC** closes it from inside.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `help` | list all commands |
|
||||||
|
| `clear` | clear terminal output |
|
||||||
|
| `go <bucket>` | switch bucket (name or: all, current, favorites, journal, unsorted) |
|
||||||
|
| `open <card>` | open card preview (fuzzy title match) |
|
||||||
|
| `edit <card>` | open card in editor |
|
||||||
|
| `new <title>` | create card in active bucket |
|
||||||
|
| `delete <card>` | delete a card |
|
||||||
|
| `tag <card> <tag>` | append tag to card body |
|
||||||
|
| `untag <card> <tag>` | remove tag from card body |
|
||||||
|
| `color <card> <hex>` | change card color |
|
||||||
|
| `move <card> <bucket>` | move card to another bucket |
|
||||||
|
| `search <query>` | open search overlay with query |
|
||||||
|
| `find <text>` | list matching cards inline |
|
||||||
|
| `calendar` | open calendar |
|
||||||
|
| `assets` | open assets gallery |
|
||||||
|
| `export` | download export ZIP |
|
||||||
|
| `sort <mode>` | change sort (manual/newest/oldest/alpha/tag) |
|
||||||
|
| `split` | toggle split preview |
|
||||||
|
| `zen` | toggle zen mode |
|
||||||
|
| `count` | show card and bucket counts |
|
||||||
|
| `tags` | list all tags |
|
||||||
|
| `buckets` | list all buckets |
|
||||||
|
| `version` | show app version |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Tab completion** on command names
|
||||||
|
- **History** with arrow up/down
|
||||||
|
- **Fuzzy matching** on card and bucket names
|
||||||
|
- Colored output: accent for success, red for errors, grey for info
|
||||||
|
- Slides up from the bottom, max 45% viewport height
|
||||||
|
- Scrollable output buffer
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"terminal"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "terminal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "In-app terminal for controlling hyprcrd with text commands. Toggle with Shift+T.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["terminal.js"],
|
||||||
|
"css": ["terminal.css"],
|
||||||
|
"html": ["terminal.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
.hterm {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 500;
|
||||||
|
background: var(--t-bg); border-top: 1px solid var(--t-border);
|
||||||
|
max-height: 45vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 -8px 32px rgba(0,0,0,0.4);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.hterm.hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
#hterm-output {
|
||||||
|
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 12px; line-height: 1.7;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.hterm-line { color: var(--t-text); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.hterm-line.cmd { color: var(--t-bright); }
|
||||||
|
.hterm-line.cmd::before { content: '> '; color: var(--t-accent); }
|
||||||
|
.hterm-line.ok { color: var(--t-accent); }
|
||||||
|
.hterm-line.err { color: var(--t-danger); }
|
||||||
|
.hterm-line.dim { color: var(--t-dim); }
|
||||||
|
.hterm-line.heading { color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; margin-top: 8px; }
|
||||||
|
|
||||||
|
.hterm-input-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 16px; border-top: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
}
|
||||||
|
.hterm-prompt {
|
||||||
|
color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 11px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#hterm-input {
|
||||||
|
flex: 1; background: transparent; border: none; color: var(--t-bright);
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 12px; outline: none;
|
||||||
|
}
|
||||||
|
#hterm-input::placeholder { color: var(--t-dim); }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div id="hterm" class="hterm hidden">
|
||||||
|
<div id="hterm-output"></div>
|
||||||
|
<div class="hterm-input-row">
|
||||||
|
<span class="hterm-prompt">></span>
|
||||||
|
<input type="text" id="hterm-input" autocomplete="off" spellcheck="false" placeholder="type a command...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// ── Terminal plugin — in-app command line ──
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const panel = $('hterm');
|
||||||
|
const output = $('hterm-output');
|
||||||
|
const input = $('hterm-input');
|
||||||
|
|
||||||
|
let visible = false;
|
||||||
|
let history = [];
|
||||||
|
let historyIdx = -1;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
visible = !visible;
|
||||||
|
panel.classList.toggle('hidden', !visible);
|
||||||
|
if (visible) { input.focus(); scrollBottom(); }
|
||||||
|
else { document.activeElement?.blur(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBottom() {
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function print(text, cls = '') {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
|
||||||
|
line.textContent = text;
|
||||||
|
output.appendChild(line);
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHtml(html, cls = '') {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
|
||||||
|
line.innerHTML = html;
|
||||||
|
output.appendChild(line);
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API helpers ──
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
return h?.api?.(path, opts) || fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts,
|
||||||
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzy(needle, haystack) {
|
||||||
|
const n = needle.toLowerCase();
|
||||||
|
return haystack.filter(s => s.toLowerCase().includes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCard(query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return window.state?.cards?.find(c => c.title.toLowerCase() === q)
|
||||||
|
|| window.state?.cards?.find(c => c.title.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBucket(query) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return window.state?.buckets?.find(b => b.name.toLowerCase() === q)
|
||||||
|
|| window.state?.buckets?.find(b => b.name.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commands ──
|
||||||
|
|
||||||
|
const COMMANDS = {};
|
||||||
|
|
||||||
|
function register(name, desc, fn) {
|
||||||
|
COMMANDS[name] = { desc, fn };
|
||||||
|
}
|
||||||
|
|
||||||
|
register('help', 'show available commands', () => {
|
||||||
|
print('COMMANDS', 'heading');
|
||||||
|
const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
|
||||||
|
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
||||||
|
print(` ${name.padEnd(maxLen + 2)} ${cmd.desc}`, 'dim');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
register('clear', 'clear terminal output', () => {
|
||||||
|
output.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
register('go', 'go <bucket> — switch to bucket', (args) => {
|
||||||
|
const name = args.join(' ');
|
||||||
|
if (!name) { print('usage: go <bucket name>', 'err'); return; }
|
||||||
|
const specials = { all: 'all', current: 'current', favorites: 'favorites', journal: 'journal', unsorted: 'none' };
|
||||||
|
if (specials[name.toLowerCase()]) {
|
||||||
|
window.state.activeBucket = specials[name.toLowerCase()];
|
||||||
|
window._renderBoard?.();
|
||||||
|
print(`switched to ${name}`, 'ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = findBucket(name);
|
||||||
|
if (!b) { print(`bucket not found: ${name}`, 'err'); return; }
|
||||||
|
window.state.activeBucket = b.id;
|
||||||
|
window._renderBoard?.();
|
||||||
|
print(`switched to ${b.name}`, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('open', 'open <card> — open card preview', async (args) => {
|
||||||
|
const q = args.join(' ');
|
||||||
|
if (!q) { print('usage: open <card title>', 'err'); return; }
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
window._openDetail?.(c.id);
|
||||||
|
print(`opened: ${c.title}`, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('edit', 'edit <card> — open card in editor', async (args) => {
|
||||||
|
const q = args.join(' ');
|
||||||
|
if (!q) { print('usage: edit <card title>', 'err'); return; }
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
window._openModal?.(c);
|
||||||
|
print(`editing: ${c.title}`, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('new', 'new <title> — create card in active bucket', async (args) => {
|
||||||
|
const title = args.join(' ');
|
||||||
|
if (!title) { print('usage: new <title>', 'err'); return; }
|
||||||
|
const bucketId = window.state?.activeBucket;
|
||||||
|
const numBucket = Number.isFinite(Number(bucketId)) ? Number(bucketId) : null;
|
||||||
|
const res = await api('api/cards', { method: 'POST', body: { title, body: '', color: '#38bdf8', bucket_id: numBucket } });
|
||||||
|
if (res?.id) {
|
||||||
|
print(`created: ${title} (id: ${res.id})`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
} else {
|
||||||
|
print(res?.error || 'failed', 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
register('delete', 'delete <card> — delete a card', async (args) => {
|
||||||
|
const q = args.join(' ');
|
||||||
|
if (!q) { print('usage: delete <card title>', 'err'); return; }
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
|
print(`deleted: ${c.title}`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
register('tag', 'tag <card> <tag> — add tag to card', async (args) => {
|
||||||
|
if (args.length < 2) { print('usage: tag <card title> <tag>', 'err'); return; }
|
||||||
|
const tag = args.pop();
|
||||||
|
const q = args.join(' ');
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
const body = (c.body || '') + `\n#${tag}`;
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
|
||||||
|
print(`tagged ${c.title} with #${tag}`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
register('untag', 'untag <card> <tag> — remove tag from card', async (args) => {
|
||||||
|
if (args.length < 2) { print('usage: untag <card title> <tag>', 'err'); return; }
|
||||||
|
const tag = args.pop();
|
||||||
|
const q = args.join(' ');
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
const body = (c.body || '').replace(new RegExp(`\\s*#${tag}\\b`, 'g'), '');
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
|
||||||
|
print(`removed #${tag} from ${c.title}`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
register('color', 'color <card> <hex> — change card color', async (args) => {
|
||||||
|
if (args.length < 2) { print('usage: color <card title> <#hex>', 'err'); return; }
|
||||||
|
const hex = args.pop();
|
||||||
|
const q = args.join(' ');
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { color: hex } });
|
||||||
|
print(`${c.title} → ${hex}`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
register('move', 'move <card> <bucket> — move card to bucket', async (args) => {
|
||||||
|
if (args.length < 2) { print('usage: move <card title> <bucket name>', 'err'); return; }
|
||||||
|
const bucketName = args.pop();
|
||||||
|
const q = args.join(' ');
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
const b = findBucket(bucketName);
|
||||||
|
if (!b) { print(`bucket not found: ${bucketName}`, 'err'); return; }
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { bucket_id: b.id } });
|
||||||
|
print(`moved ${c.title} → ${b.name}`, 'ok');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
register('search', 'search <query> — open search with query', (args) => {
|
||||||
|
const q = args.join(' ');
|
||||||
|
window._closeSearch?.();
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const overlay = document.getElementById('search');
|
||||||
|
if (overlay) overlay.classList.add('open');
|
||||||
|
if (searchInput) { searchInput.value = q; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }
|
||||||
|
print(`searching: ${q}`, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); });
|
||||||
|
register('tim', 'open time calculator', () => {
|
||||||
|
if (window._timToggle) { window._timToggle(); print('tim opened', 'ok'); }
|
||||||
|
else print('tim plugin not loaded', 'err');
|
||||||
|
});
|
||||||
|
register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
|
||||||
|
|
||||||
|
register('export', 'download export ZIP', () => {
|
||||||
|
document.getElementById('export-btn')?.click();
|
||||||
|
print('export started', 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('sort', 'sort <mode> — manual/newest/oldest/alpha/tag', (args) => {
|
||||||
|
const mode = args[0];
|
||||||
|
if (!mode) { print('usage: sort <manual|newest|oldest|alpha|tag>', 'err'); return; }
|
||||||
|
const sortBtn = document.getElementById('sort-toggle');
|
||||||
|
if (sortBtn) {
|
||||||
|
const label = document.getElementById('sort-label');
|
||||||
|
if (label) label.textContent = mode;
|
||||||
|
window.state.sortMode = mode;
|
||||||
|
window._renderBoard?.();
|
||||||
|
print(`sort: ${mode}`, 'ok');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
register('split', 'toggle split preview', () => {
|
||||||
|
document.getElementById('board-split-toggle')?.click();
|
||||||
|
print('split toggled', 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('zen', 'toggle zen mode on current detail', () => {
|
||||||
|
document.getElementById('detail-zen')?.click();
|
||||||
|
print('zen toggled', 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('count', 'show card and bucket counts', () => {
|
||||||
|
const cards = window.state?.cards?.length || 0;
|
||||||
|
const buckets = window.state?.buckets?.length || 0;
|
||||||
|
print(`${cards} cards · ${buckets} buckets`, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('tags', 'list all tags', () => {
|
||||||
|
const tagSet = new Set();
|
||||||
|
for (const c of window.state?.cards || []) {
|
||||||
|
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
|
||||||
|
}
|
||||||
|
if (!tagSet.size) { print('no tags', 'dim'); return; }
|
||||||
|
const sorted = [...tagSet].sort();
|
||||||
|
print(`${sorted.length} tags:`, 'heading');
|
||||||
|
print(sorted.map(t => '#' + t).join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
register('buckets', 'list all buckets', () => {
|
||||||
|
const bs = window.state?.buckets || [];
|
||||||
|
if (!bs.length) { print('no buckets', 'dim'); return; }
|
||||||
|
print(`${bs.length} buckets:`, 'heading');
|
||||||
|
for (const b of bs) print(` ${b.name} (${b.id})${b.parent_id ? ` → child of ${b.parent_id}` : ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
register('history', 'history <card> — show edit history', async (args) => {
|
||||||
|
const q = args.join(' ');
|
||||||
|
if (!q) { print('usage: history <card title>', 'err'); return; }
|
||||||
|
const c = findCard(q);
|
||||||
|
if (!c) { print(`card not found: ${q}`, 'err'); return; }
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/cards/${c.id}/history`);
|
||||||
|
entries = await res.json();
|
||||||
|
} catch (e) { print('failed to fetch history', 'err'); return; }
|
||||||
|
if (!entries || !entries.length) { print(`${c.title}: no edit history`, 'dim'); return; }
|
||||||
|
print(`${c.title} — ${entries.length} changes:`, 'heading');
|
||||||
|
for (const e of entries) {
|
||||||
|
const old = (e.old_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
|
||||||
|
const nw = (e.new_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
|
||||||
|
const time = e.changed_at?.substring(0, 16) || '?';
|
||||||
|
print(` ${time} ${e.field}: ${old} → ${nw}`, 'dim');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
register('find', 'find <text> — list matching cards', (args) => {
|
||||||
|
const q = args.join(' ').toLowerCase();
|
||||||
|
if (!q) { print('usage: find <text>', 'err'); return; }
|
||||||
|
const hits = (window.state?.cards || []).filter(c =>
|
||||||
|
c.title.toLowerCase().includes(q) || (c.body || '').toLowerCase().includes(q) || (c.tags || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
if (!hits.length) { print('no matches', 'dim'); return; }
|
||||||
|
print(`${hits.length} matches:`, 'heading');
|
||||||
|
for (const c of hits.slice(0, 20)) {
|
||||||
|
print(` ${c.title}${c.tags ? ' ' + (c.tags || '').split(',').map(t => '#' + t).join(' ') : ''}`, 'dim');
|
||||||
|
}
|
||||||
|
if (hits.length > 20) print(` ... and ${hits.length - 20} more`, 'dim');
|
||||||
|
});
|
||||||
|
|
||||||
|
register('version', 'show app version', () => {
|
||||||
|
const v = document.querySelector('[data-app-version]')?.textContent || '?';
|
||||||
|
print(v, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab completion ──
|
||||||
|
|
||||||
|
function tabComplete() {
|
||||||
|
const val = input.value;
|
||||||
|
const parts = val.split(/\s+/);
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
const matches = fuzzy(parts[0] || '', Object.keys(COMMANDS));
|
||||||
|
if (matches.length === 1) { input.value = matches[0] + ' '; }
|
||||||
|
else if (matches.length > 1) { print(matches.join(' '), 'dim'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execute ──
|
||||||
|
|
||||||
|
async function exec(line) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
history.push(trimmed);
|
||||||
|
historyIdx = history.length;
|
||||||
|
print(trimmed, 'cmd');
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
const cmd = parts.shift().toLowerCase();
|
||||||
|
const command = COMMANDS[cmd];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
print(`unknown command: ${cmd}. Type "help" for a list.`, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await command.fn(parts);
|
||||||
|
} catch (e) {
|
||||||
|
print(`error: ${e.message}`, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers ──
|
||||||
|
|
||||||
|
input?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const val = input.value;
|
||||||
|
input.value = '';
|
||||||
|
exec(val);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
tabComplete();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIdx > 0) { historyIdx--; input.value = history[historyIdx]; }
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; }
|
||||||
|
else { historyIdx = history.length; input.value = ''; }
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shift+T toggle (capture, before shortcuts)
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'T' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
print('hyprcrd terminal — type "help" for commands, Shift+T to toggle', 'dim');
|
||||||
|
|
||||||
|
h?.registerPlugin({ name: 'terminal', version: '1.0.0' });
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
const card = document.querySelector('#board [data-id]');
|
||||||
|
if (!card) return false;
|
||||||
|
card.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if ($('#modal')?.classList.contains('open')) return true;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
click('#md-asset-picker');
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (document.getElementById('asset-picker-flyout')) return true;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Asset Picker', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
document.getElementById('asset-picker-flyout')?.remove();
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toolbar button exists', () => {
|
||||||
|
expect($('#md-asset-picker')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens flyout with search and grid, toggles closed on second click', async () => {
|
||||||
|
if (!await openEditor()) return;
|
||||||
|
if (!await openPicker()) return;
|
||||||
|
|
||||||
|
expect(document.getElementById('asset-picker-flyout')).toExist();
|
||||||
|
expect(document.getElementById('asset-picker-search')).toExist();
|
||||||
|
expect(document.getElementById('asset-picker-grid')).toExist();
|
||||||
|
|
||||||
|
// Second click closes
|
||||||
|
click('#md-asset-picker');
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (!document.getElementById('asset-picker-flyout')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
expect(document.getElementById('asset-picker-flyout')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search input filters tiles', async () => {
|
||||||
|
if (!await openEditor()) return;
|
||||||
|
if (!await openPicker()) return;
|
||||||
|
|
||||||
|
const search = document.getElementById('asset-picker-search');
|
||||||
|
if (!search) return;
|
||||||
|
|
||||||
|
search.value = 'zzz_nonexistent_zzz';
|
||||||
|
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
const grid = document.getElementById('asset-picker-grid');
|
||||||
|
expect(grid.querySelectorAll('.asset-pick-tile').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ describe('Calendar', () => {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
click('#cal-today');
|
click('#cal-today');
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]);
|
expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,4 +58,25 @@ describe('Calendar', () => {
|
|||||||
await sleep(150);
|
await sleep(150);
|
||||||
expect($('#calendar')).toBeHidden();
|
expect($('#calendar')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves month across close/reopen', async () => {
|
||||||
|
// Open calendar and navigate forward 3 months
|
||||||
|
click('#calendar-btn');
|
||||||
|
await sleep(200);
|
||||||
|
click('#cal-next');
|
||||||
|
click('#cal-next');
|
||||||
|
click('#cal-next');
|
||||||
|
await sleep(200);
|
||||||
|
const navigatedTitle = $('#cal-title').textContent;
|
||||||
|
|
||||||
|
// Close
|
||||||
|
click('#cal-close');
|
||||||
|
await sleep(150);
|
||||||
|
expect($('#calendar')).toBeHidden();
|
||||||
|
|
||||||
|
// Reopen — must stay on the navigated month, not jump to today
|
||||||
|
click('#calendar-btn');
|
||||||
|
await sleep(200);
|
||||||
|
expect($('#cal-title').textContent).toBe(navigatedTitle);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
|
|||||||
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
||||||
|
|
||||||
click(doneBtn);
|
click(doneBtn);
|
||||||
await sleep(300);
|
// Poll until #done is persisted to the DB
|
||||||
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
let updated;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100);
|
||||||
|
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||||
|
if ((updated[0]?.tags || '').includes('done')) break;
|
||||||
|
}
|
||||||
expect((updated[0].body || '').toLowerCase()).toContain('#done');
|
expect((updated[0].body || '').toLowerCase()).toContain('#done');
|
||||||
expect(updated[0].tags || '').toContain('done');
|
expect(updated[0].tags || '').toContain('done');
|
||||||
|
|
||||||
|
// Wait for the board to re-render with the updated card data
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const card = window.state?.cards?.find(c => c.id == res.id);
|
||||||
|
if (card && (card.tags || '').includes('done')) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-open menu — the entry should now flip to "Unmark done"
|
// Re-open menu — the entry should now flip to "Unmark done"
|
||||||
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
||||||
await sleep(150);
|
await sleep(200);
|
||||||
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
||||||
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
||||||
|
|
||||||
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
|
|||||||
// Confirm the delete
|
// Confirm the delete
|
||||||
const okBtn = document.getElementById('dialog-ok');
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
if (okBtn) click(okBtn);
|
if (okBtn) click(okBtn);
|
||||||
await sleep(500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All three cards should be gone
|
// Poll until all three cards are gone (group delete = N serial DELETEs)
|
||||||
const remaining = await fetch('api/cards').then(r => r.json());
|
let remaining;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100);
|
||||||
|
remaining = await fetch('api/cards').then(r => r.json());
|
||||||
|
const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id);
|
||||||
|
if (!stillThere) break;
|
||||||
|
}
|
||||||
expect(remaining.some(c => c.id === parent.id)).toBe(false);
|
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 === child1.id)).toBe(false);
|
||||||
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
||||||
@@ -375,11 +392,18 @@ describe('Card: Group Delete', () => {
|
|||||||
if (dialog?.classList.contains('open')) {
|
if (dialog?.classList.contains('open')) {
|
||||||
const okBtn = document.getElementById('dialog-ok');
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
if (okBtn) click(okBtn);
|
if (okBtn) click(okBtn);
|
||||||
await sleep(500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm both are gone
|
// Poll until deletion lands (can take a while on slow machines with
|
||||||
let cards = await fetch('api/cards').then(r => r.json());
|
// confirm-dialog + two DELETE calls chained).
|
||||||
|
let cards;
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 4000) {
|
||||||
|
cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
const stillThere = cards.some(c => c.id === parent.id || c.id === child.id);
|
||||||
|
if (!stillThere) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
expect(cards.some(c => c.id === parent.id)).toBe(false);
|
expect(cards.some(c => c.id === parent.id)).toBe(false);
|
||||||
expect(cards.some(c => c.id === child.id)).toBe(false);
|
expect(cards.some(c => c.id === child.id)).toBe(false);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, click, $, sleep, waitFor } from './runner.js?v=4';
|
||||||
|
|
||||||
|
// Chat plugin tests — UI-focused. No send/receive here: that would
|
||||||
|
// require a real relay, and the user's "no mocks" rule means we don't
|
||||||
|
// fake one. Runs against the real /api/chat endpoints, which respond
|
||||||
|
// cleanly on a fresh install (empty conversation list).
|
||||||
|
|
||||||
|
const ensureFlyoutClosed = async () => {
|
||||||
|
const f = $('#chat-flyout');
|
||||||
|
if (!f) return;
|
||||||
|
if (!f.classList.contains('hidden')) {
|
||||||
|
click('#chat-close-btn');
|
||||||
|
await waitFor(() => f.classList.contains('hidden'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Chat: flyout', () => {
|
||||||
|
it('footer button is injected', () => {
|
||||||
|
expect($('#chat-footer-btn')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles open and closed', async () => {
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
const f = $('#chat-flyout');
|
||||||
|
click('#chat-footer-btn');
|
||||||
|
await waitFor(() => !f.classList.contains('hidden'));
|
||||||
|
expect(f.classList.contains('hidden')).toBe(false);
|
||||||
|
click('#chat-close-btn');
|
||||||
|
await waitFor(() => f.classList.contains('hidden'));
|
||||||
|
expect(f.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header-back visibility matches active view', async () => {
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
click('#chat-footer-btn');
|
||||||
|
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
||||||
|
// Wait for loadConversations + initial view pick to settle.
|
||||||
|
await sleep(150);
|
||||||
|
const back = $('#chat-header-back');
|
||||||
|
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
|
||||||
|
const listShown = !$('#chat-list-view').classList.contains('hidden');
|
||||||
|
// Exactly one of thread / list should be active here (pick view
|
||||||
|
// is only reached via the + button).
|
||||||
|
expect(threadShown || listShown).toBe(true);
|
||||||
|
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
|
||||||
|
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new-conversation button opens pick view', async () => {
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
click('#chat-footer-btn');
|
||||||
|
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
||||||
|
await sleep(100);
|
||||||
|
click('#chat-new-btn');
|
||||||
|
await sleep(50);
|
||||||
|
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
|
||||||
|
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
|
||||||
|
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
|
||||||
|
// Header back is only for thread view — not needed when pick has
|
||||||
|
// its own dedicated back button.
|
||||||
|
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
|
||||||
|
click('#chat-pick-back');
|
||||||
|
await sleep(50);
|
||||||
|
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
|
||||||
|
await ensureFlyoutClosed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chat: thread DOM structure', () => {
|
||||||
|
it('peer-strip element lives inside thread view', () => {
|
||||||
|
const strip = $('#chat-thread-peers');
|
||||||
|
expect(strip).toExist();
|
||||||
|
expect(strip.closest('#chat-thread-view')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thread form, input and send button exist', () => {
|
||||||
|
expect($('#chat-thread-form')).toExist();
|
||||||
|
expect($('#chat-thread-input')).toExist();
|
||||||
|
expect($('#chat-thread-send')).toExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chat: API', () => {
|
||||||
|
it('/api/chat/conversations returns expected shape', async () => {
|
||||||
|
const res = await fetch('api/chat/conversations').then(r => r.json());
|
||||||
|
expect(Array.isArray(res.conversations)).toBe(true);
|
||||||
|
expect(typeof res.total_unread).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/chat/messages without peer returns 400', async () => {
|
||||||
|
const r = await fetch('api/chat/messages');
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/api/chat/messages with unknown peer returns empty array', async () => {
|
||||||
|
const res = await fetch('api/chat/messages?peer=__test_no_such_peer__')
|
||||||
|
.then(r => r.json());
|
||||||
|
expect(Array.isArray(res.messages)).toBe(true);
|
||||||
|
expect(res.messages.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,8 +14,6 @@ describe('RRULE: Data Model', () => {
|
|||||||
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
|
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
|
||||||
for (const b of birthdays) {
|
for (const b of birthdays) {
|
||||||
expect(b.tags).toContain('geburtstag');
|
expect(b.tags).toContain('geburtstag');
|
||||||
// Birthday titles start with date
|
|
||||||
expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// Backup is created before and auto-restored after every run.
|
// Backup is created before and auto-restored after every run.
|
||||||
|
|
||||||
import { getCsrf } from '../../js/modules/util.js';
|
import { getCsrf } from '../../js/modules/util.js';
|
||||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/sync.js';
|
||||||
|
|
||||||
// Suites live on window so two runner.js instances (one cached and loaded
|
// Suites live on window so two runner.js instances (one cached and loaded
|
||||||
// by test files via bare imports, one fresh and loaded by loader.js with
|
// by test files via bare imports, one fresh and loaded by loader.js with
|
||||||
@@ -170,7 +170,73 @@ export function csrf() {
|
|||||||
return getCsrf();
|
return getCsrf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the view-affecting bits of window.state so we can force a
|
||||||
|
// neutral baseline (normal board, no filter, manual sort, no hides)
|
||||||
|
// for the test run and restore the user's view afterwards. Tests were
|
||||||
|
// flaky when the runner fired while the board was in super-view: DOM
|
||||||
|
// selectors like `.card[data-id]` don't match `.superview-item`, so
|
||||||
|
// multi-select and due-date tests read wrong initial values.
|
||||||
|
export function snapshotViewState() {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s) return null;
|
||||||
|
return {
|
||||||
|
superView: s.superView,
|
||||||
|
activeBucket: s.activeBucket,
|
||||||
|
activeTags: [...(s.activeTags || [])],
|
||||||
|
activeGroup: s.activeGroup,
|
||||||
|
sortMode: s.sortMode,
|
||||||
|
hideTermine: s.hideTermine,
|
||||||
|
hideDone: s.hideDone,
|
||||||
|
hideTagged: s.hideTagged,
|
||||||
|
boardSplitOpen: s.boardSplitOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetViewState() {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s) return;
|
||||||
|
s.superView = false;
|
||||||
|
s.activeBucket = 'all';
|
||||||
|
s.activeTags = [];
|
||||||
|
s.activeGroup = null;
|
||||||
|
s.sortMode = 'manual';
|
||||||
|
s.hideTermine = false;
|
||||||
|
s.hideDone = false;
|
||||||
|
s.hideTagged = false;
|
||||||
|
s.boardSplitOpen = false;
|
||||||
|
s.selectedIds?.clear?.();
|
||||||
|
// Sync super-view toggle visuals with the reset state so tests that
|
||||||
|
// inspect the button see the correct pose.
|
||||||
|
const btn = document.getElementById('superview-toggle');
|
||||||
|
const icon = btn?.querySelector('i');
|
||||||
|
if (icon) icon.className = 'ph ph-list text-sm';
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreViewState(snap) {
|
||||||
|
const s = window.state;
|
||||||
|
if (!s || !snap) return;
|
||||||
|
Object.assign(s, {
|
||||||
|
superView: snap.superView,
|
||||||
|
activeBucket: snap.activeBucket,
|
||||||
|
activeTags: snap.activeTags,
|
||||||
|
activeGroup: snap.activeGroup,
|
||||||
|
sortMode: snap.sortMode,
|
||||||
|
hideTermine: snap.hideTermine,
|
||||||
|
hideDone: snap.hideDone,
|
||||||
|
hideTagged: snap.hideTagged,
|
||||||
|
boardSplitOpen: snap.boardSplitOpen,
|
||||||
|
});
|
||||||
|
const btn = document.getElementById('superview-toggle');
|
||||||
|
const icon = btn?.querySelector('i');
|
||||||
|
if (icon) icon.className = `ph ${snap.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
|
||||||
|
btn?.classList.toggle('active', !!snap.superView);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeAll() {
|
export async function closeAll() {
|
||||||
|
document.getElementById('hterm')?.classList.add('hidden');
|
||||||
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
if (el?.classList.contains('open')) el.classList.remove('open');
|
||||||
@@ -180,6 +246,7 @@ export async function closeAll() {
|
|||||||
document.querySelector('.bucket-picker')?.remove();
|
document.querySelector('.bucket-picker')?.remove();
|
||||||
document.querySelector('.orphan-lightbox')?.remove();
|
document.querySelector('.orphan-lightbox')?.remove();
|
||||||
document.getElementById('bucket-super-popover')?.remove();
|
document.getElementById('bucket-super-popover')?.remove();
|
||||||
|
document.getElementById('asset-picker-flyout')?.remove();
|
||||||
document.getElementById('gitea-overlay')?.classList.remove('open');
|
document.getElementById('gitea-overlay')?.classList.remove('open');
|
||||||
// Clear multi-select
|
// Clear multi-select
|
||||||
document.querySelectorAll('.card-selected').forEach(el => {
|
document.querySelectorAll('.card-selected').forEach(el => {
|
||||||
@@ -199,6 +266,37 @@ async function createBackup() {
|
|||||||
backupBlob = await res.blob();
|
backupBlob = await res.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge leftover test artifacts from previous failed runs.
|
||||||
|
// Tests use `__prefix__` naming; anything matching gets deleted before
|
||||||
|
// the baseline backup so it doesn't get restored after the run either.
|
||||||
|
async function purgeTestLeftovers() {
|
||||||
|
const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf();
|
||||||
|
let purged = { cards: 0, buckets: 0 };
|
||||||
|
try {
|
||||||
|
const cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
const testCards = cards.filter(c => /^__.*__/.test(c.title || ''));
|
||||||
|
for (const c of testCards) {
|
||||||
|
await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
|
||||||
|
purged.cards++;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || ''));
|
||||||
|
for (const b of testBuckets) {
|
||||||
|
await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
|
||||||
|
purged.buckets++;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Also clear any leftover editor drafts so the restore prompt doesn't fire
|
||||||
|
try {
|
||||||
|
for (const k of Object.keys(localStorage)) {
|
||||||
|
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return purged;
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreBackup() {
|
async function restoreBackup() {
|
||||||
if (!backupBlob) return;
|
if (!backupBlob) return;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -396,10 +494,18 @@ async function run() {
|
|||||||
const output = panel.querySelector('#test-output');
|
const output = panel.querySelector('#test-output');
|
||||||
const summary = panel.querySelector('#test-summary');
|
const summary = panel.querySelector('#test-summary');
|
||||||
|
|
||||||
// 1. Create backup
|
// 1. Purge leftover test artifacts from any previous failed run
|
||||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
output.innerHTML = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
|
||||||
summary.textContent = '';
|
summary.textContent = '';
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
|
const purged = await purgeTestLeftovers();
|
||||||
|
if (purged.cards || purged.buckets) {
|
||||||
|
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create backup (clean baseline)
|
||||||
|
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
|
||||||
|
await sleep(50);
|
||||||
try {
|
try {
|
||||||
await createBackup();
|
await createBackup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -411,6 +517,15 @@ async function run() {
|
|||||||
stopBackgroundSync();
|
stopBackgroundSync();
|
||||||
window._testRunning = true;
|
window._testRunning = true;
|
||||||
|
|
||||||
|
// 2b. Snapshot + reset view state. Tests assume a normal board with
|
||||||
|
// no filters / super-view off / manual sort — if the user left the
|
||||||
|
// app in super-view or with a bucket filter active, assertions on
|
||||||
|
// .card[data-id], card count, etc. read wrong initial values.
|
||||||
|
const viewSnap = snapshotViewState();
|
||||||
|
await closeAll();
|
||||||
|
resetViewState();
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
// 3. Hide panel so tests run against visible UI
|
// 3. Hide panel so tests run against visible UI
|
||||||
panel.style.display = 'none';
|
panel.style.display = 'none';
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
@@ -435,6 +550,11 @@ async function run() {
|
|||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Re-reset the view in case a failing test left it in super-view,
|
||||||
|
// a bucket filter, or a sorted mode — otherwise the retry runs
|
||||||
|
// against the same wrong baseline that flaked it the first time.
|
||||||
|
resetViewState();
|
||||||
|
await sleep(50);
|
||||||
const retryResults = await runAll(failedKeys);
|
const retryResults = await runAll(failedKeys);
|
||||||
const retryMap = new Map();
|
const retryMap = new Map();
|
||||||
for (const sr of retryResults) {
|
for (const sr of retryResults) {
|
||||||
@@ -467,6 +587,11 @@ async function run() {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// 6b. Put the view back the way the user left it (super-view, sort,
|
||||||
|
// filters, …) so launching the test runner doesn't silently wipe
|
||||||
|
// their working context.
|
||||||
|
restoreViewState(viewSnap);
|
||||||
|
|
||||||
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
||||||
window._testRunning = false;
|
window._testRunning = false;
|
||||||
setTimeout(() => startBackgroundSync(), 60000);
|
setTimeout(() => startBackgroundSync(), 60000);
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ describe('Super-buckets: Bar rendering', () => {
|
|||||||
expect(superTile.textContent).toContain('__sb_active_child__');
|
expect(superTile.textContent).toContain('__sb_active_child__');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking the super-tile opens a popover with parent + child entries', async () => {
|
it('clicking the caret opens a popover with parent + child entries', async () => {
|
||||||
const parent = await makeBucket('__sb_pop_parent__');
|
const parent = await makeBucket('__sb_pop_parent__');
|
||||||
const child1 = await makeBucket('__sb_pop_child1__');
|
const child1 = await makeBucket('__sb_pop_child1__');
|
||||||
const child2 = await makeBucket('__sb_pop_child2__');
|
const child2 = await makeBucket('__sb_pop_child2__');
|
||||||
@@ -191,15 +191,15 @@ describe('Super-buckets: Bar rendering', () => {
|
|||||||
|
|
||||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
if (!superTile) return;
|
if (!superTile) return;
|
||||||
click(superTile);
|
const caret = superTile.querySelector('[data-super-caret]');
|
||||||
|
expect(caret).toExist();
|
||||||
|
click(caret);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
|
|
||||||
const popover = document.getElementById('bucket-super-popover');
|
const popover = document.getElementById('bucket-super-popover');
|
||||||
expect(popover).toExist();
|
expect(popover).toExist();
|
||||||
// Parent + 2 children = 3 entries
|
|
||||||
const entries = popover.querySelectorAll('[data-bucket]');
|
const entries = popover.querySelectorAll('[data-bucket]');
|
||||||
expect(entries.length).toBe(3);
|
expect(entries.length).toBe(3);
|
||||||
// All three names appear
|
|
||||||
const text = popover.textContent;
|
const text = popover.textContent;
|
||||||
expect(text).toContain('__sb_pop_parent__');
|
expect(text).toContain('__sb_pop_parent__');
|
||||||
expect(text).toContain('__sb_pop_child1__');
|
expect(text).toContain('__sb_pop_child1__');
|
||||||
@@ -215,7 +215,8 @@ describe('Super-buckets: Bar rendering', () => {
|
|||||||
|
|
||||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
if (!superTile) return;
|
if (!superTile) return;
|
||||||
click(superTile);
|
const caret = superTile.querySelector('[data-super-caret]');
|
||||||
|
click(caret);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
|
|
||||||
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
|
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
|
||||||
@@ -226,7 +227,25 @@ describe('Super-buckets: Bar rendering', () => {
|
|||||||
expect(String(state.activeBucket)).toBe(String(child.id));
|
expect(String(state.activeBucket)).toBe(String(child.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking the super-tile twice toggles the popover closed', async () => {
|
it('clicking the super-tile activates the shown bucket', async () => {
|
||||||
|
const parent = await makeBucket('__sb_direct_parent__');
|
||||||
|
const child = await makeBucket('__sb_direct_child__');
|
||||||
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
state.activeBucket = 'all';
|
||||||
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
|
if (!superTile) return;
|
||||||
|
click(superTile);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
const shownId = superTile.dataset.superShown;
|
||||||
|
expect(String(state.activeBucket)).toBe(String(shownId));
|
||||||
|
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the caret twice toggles the popover closed', async () => {
|
||||||
const parent = await makeBucket('__sb_toggle_parent__');
|
const parent = await makeBucket('__sb_toggle_parent__');
|
||||||
const child = await makeBucket('__sb_toggle_child__');
|
const child = await makeBucket('__sb_toggle_child__');
|
||||||
await patchBucket(child.id, { parent_id: parent.id });
|
await patchBucket(child.id, { parent_id: parent.id });
|
||||||
@@ -235,10 +254,11 @@ describe('Super-buckets: Bar rendering', () => {
|
|||||||
|
|
||||||
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
|
||||||
if (!superTile) return;
|
if (!superTile) return;
|
||||||
click(superTile);
|
const caret = superTile.querySelector('[data-super-caret]');
|
||||||
|
click(caret);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
expect(document.getElementById('bucket-super-popover')).toExist();
|
expect(document.getElementById('bucket-super-popover')).toExist();
|
||||||
click(superTile);
|
click(caret);
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
expect(document.getElementById('bucket-super-popover')).toBe(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
import { state } from '../../js/modules/state.js';
|
||||||
|
|
||||||
|
function termToggle() {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'T', shiftKey: true, bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function termExec(cmd) {
|
||||||
|
const input = document.getElementById('hterm-input');
|
||||||
|
if (!input) return;
|
||||||
|
input.value = cmd;
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function termOutput() {
|
||||||
|
return document.getElementById('hterm-output')?.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Terminal: Toggle', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
document.getElementById('hterm')?.classList.add('hidden');
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('panel exists but is hidden', () => {
|
||||||
|
const panel = document.getElementById('hterm');
|
||||||
|
expect(panel).toExist();
|
||||||
|
expect(panel.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+T opens the terminal', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+T again closes it', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESC closes the terminal', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
const input = document.getElementById('hterm-input');
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Terminal: Commands', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
document.getElementById('hterm')?.classList.add('hidden');
|
||||||
|
document.getElementById('hterm-output').innerHTML = '';
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('help lists commands', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('help');
|
||||||
|
await sleep(50);
|
||||||
|
const out = termOutput();
|
||||||
|
expect(out).toContain('help');
|
||||||
|
expect(out).toContain('new');
|
||||||
|
expect(out).toContain('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count shows numbers', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('count');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput()).toContain('cards');
|
||||||
|
expect(termOutput()).toContain('buckets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('version shows version string', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('version');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput()).toContain('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown command shows error', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('xyznonexistent');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput()).toContain('unknown command');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags lists existing tags', async () => {
|
||||||
|
await makeCard('__term_tag_test__', '#alpha #beta');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('tags');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput()).toContain('#alpha');
|
||||||
|
expect(termOutput()).toContain('#beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('find matches cards by title', async () => {
|
||||||
|
await makeCard('__term_find_me__', 'findable body');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('find __term_find_me__');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput()).toContain('__term_find_me__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('go switches to bucket', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('go all');
|
||||||
|
await sleep(100);
|
||||||
|
expect(String(state.activeBucket)).toBe('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new creates a card', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('new __term_new_card__');
|
||||||
|
// termExec fires off an async API call + full reload; poll rather
|
||||||
|
// than sleeping a fixed amount so slow machines don't flake.
|
||||||
|
const start = Date.now();
|
||||||
|
while (!termOutput().includes('created') && Date.now() - start < 3000) {
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
expect(termOutput()).toContain('created');
|
||||||
|
const cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear empties output', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
termExec('help');
|
||||||
|
await sleep(50);
|
||||||
|
expect(termOutput().length).toBeGreaterThan(10);
|
||||||
|
termExec('clear');
|
||||||
|
await sleep(50);
|
||||||
|
expect(document.getElementById('hterm-output').children.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Terminal: Tab Completion', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
document.getElementById('hterm')?.classList.add('hidden');
|
||||||
|
document.getElementById('hterm-output').innerHTML = '';
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab completes unique command', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
const input = document.getElementById('hterm-input');
|
||||||
|
input.value = 'vers';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
expect(input.value).toBe('version ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab shows multiple matches', async () => {
|
||||||
|
termToggle();
|
||||||
|
await sleep(50);
|
||||||
|
const input = document.getElementById('hterm-input');
|
||||||
|
input.value = 'c';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
const out = termOutput();
|
||||||
|
expect(out).toContain('clear');
|
||||||
|
expect(out).toContain('count');
|
||||||
|
expect(out).toContain('calendar');
|
||||||
|
expect(out).toContain('color');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ const FILES = [
|
|||||||
'./editor-toolbar.test.js',
|
'./editor-toolbar.test.js',
|
||||||
'./line-numbers.test.js',
|
'./line-numbers.test.js',
|
||||||
'./canvas.test.js',
|
'./canvas.test.js',
|
||||||
|
'./tim.test.js',
|
||||||
'./snippets.test.js',
|
'./snippets.test.js',
|
||||||
'./image-fullscreen.test.js',
|
'./image-fullscreen.test.js',
|
||||||
'./markdown.test.js',
|
'./markdown.test.js',
|
||||||
@@ -50,7 +51,10 @@ const FILES = [
|
|||||||
'./relay.test.js',
|
'./relay.test.js',
|
||||||
'./notifications.test.js',
|
'./notifications.test.js',
|
||||||
'./quick-capture.test.js',
|
'./quick-capture.test.js',
|
||||||
|
'./asset-picker.test.js',
|
||||||
|
'./terminal.test.js',
|
||||||
'./theme.test.js',
|
'./theme.test.js',
|
||||||
|
'./chat.test.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Await all imports so the runner can start in a deterministic state.
|
// Await all imports so the runner can start in a deterministic state.
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function openTim() {
|
||||||
|
window._timToggle?.();
|
||||||
|
}
|
||||||
|
function closeTim() {
|
||||||
|
const overlay = $('#tim-overlay');
|
||||||
|
if (overlay && !overlay.classList.contains('hidden')) window._timToggle?.();
|
||||||
|
}
|
||||||
|
function isOpen() {
|
||||||
|
return !$('#tim-overlay').classList.contains('hidden');
|
||||||
|
}
|
||||||
|
function setInput(id, value) {
|
||||||
|
const el = $('#' + id);
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
function activateTab(name) {
|
||||||
|
const tab = document.querySelector(`.tim-tab[data-tim-tab="${name}"]`);
|
||||||
|
tab.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Registration', () => {
|
||||||
|
it('plugin is registered with hyprcrd', () => {
|
||||||
|
const meta = window.hyprcrd.plugins.find(p => p.name === 'tim');
|
||||||
|
expect(meta).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tim editor command is registered', () => {
|
||||||
|
expect(window.hyprcrd.editor.getCommand('tim')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toolbar clock button is injected', () => {
|
||||||
|
const btn = document.querySelector('#md-toolbar [data-md="tim"]');
|
||||||
|
expect(btn).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('window._timToggle is exposed', () => {
|
||||||
|
expect(typeof window._timToggle).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Structure ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Structure', () => {
|
||||||
|
it('overlay and panel exist in DOM', () => {
|
||||||
|
expect($('#tim-overlay')).toExist();
|
||||||
|
expect(document.querySelector('.tim-panel')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlay is hidden initially', () => {
|
||||||
|
expect($('#tim-overlay').classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all three tabs exist', () => {
|
||||||
|
expect(document.querySelector('.tim-tab[data-tim-tab="duration"]')).toExist();
|
||||||
|
expect(document.querySelector('.tim-tab[data-tim-tab="add"]')).toExist();
|
||||||
|
expect(document.querySelector('.tim-tab[data-tim-tab="workday"]')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all inputs + result fields exist', () => {
|
||||||
|
const ids = [
|
||||||
|
'tim-dur-start', 'tim-dur-end', 'tim-dur-result',
|
||||||
|
'tim-add-time', 'tim-add-dur', 'tim-add-result',
|
||||||
|
'tim-wd-start', 'tim-wd-break-from', 'tim-wd-break-back',
|
||||||
|
'tim-wd-target', 'tim-wd-result',
|
||||||
|
];
|
||||||
|
for (const id of ids) expect($('#' + id)).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('target input has no default value (placeholder only)', () => {
|
||||||
|
expect($('#tim-wd-target').value).toBe('');
|
||||||
|
expect($('#tim-wd-target').placeholder).toBe('0800');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Open / Close ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Open & Close', () => {
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
it('toolbar button opens tim', async () => {
|
||||||
|
const btn = document.querySelector('#md-toolbar [data-md="tim"]');
|
||||||
|
btn.click();
|
||||||
|
await sleep(30);
|
||||||
|
expect(isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_timToggle toggles visibility', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
expect(isOpen()).toBe(true);
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
expect(isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close button hides overlay', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
click('#tim-close');
|
||||||
|
await sleep(30);
|
||||||
|
expect(isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes overlay', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await sleep(30);
|
||||||
|
expect(isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking backdrop closes overlay', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
const overlay = $('#tim-overlay');
|
||||||
|
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
// Backdrop-click requires e.target === overlay, which happens naturally
|
||||||
|
// for a click on the overlay itself.
|
||||||
|
await sleep(30);
|
||||||
|
expect(isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab switching ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Tabs', () => {
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
it('duration tab is active on load', () => {
|
||||||
|
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
|
||||||
|
expect(tab.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Add tab activates it and shows its panel', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('add');
|
||||||
|
await sleep(20);
|
||||||
|
expect(document.querySelector('.tim-tab[data-tim-tab="add"]').classList.contains('active')).toBe(true);
|
||||||
|
expect(document.querySelector('[data-tim-panel="add"]').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.querySelector('[data-tim-panel="duration"]').classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to Workday reveals all four workday inputs', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday');
|
||||||
|
await sleep(20);
|
||||||
|
const panel = document.querySelector('[data-tim-panel="workday"]');
|
||||||
|
expect(panel.classList.contains('hidden')).toBe(false);
|
||||||
|
expect(panel.querySelectorAll('input').length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Duration calculation ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Duration Calc', () => {
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
it('0830 → 1715 is 8h 45m', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
setInput('tim-dur-start', '0830');
|
||||||
|
setInput('tim-dur-end', '1715');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('8h 45m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps past midnight (2200 → 0200 = 4h 00m)', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
setInput('tim-dur-start', '2200');
|
||||||
|
setInput('tim-dur-end', '0200');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('4h 00m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colon format (08:30) is accepted', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
setInput('tim-dur-start', '08:30');
|
||||||
|
setInput('tim-dur-end', '09:00');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('0h 30m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty input clears the result', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
setInput('tim-dur-start', '0800');
|
||||||
|
setInput('tim-dur-end', '0900');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('1h 00m');
|
||||||
|
setInput('tim-dur-end', '');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid input (minutes >= 60) clears the result', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
setInput('tim-dur-start', '0880');
|
||||||
|
setInput('tim-dur-end', '1000');
|
||||||
|
expect($('#tim-dur-result').textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add calculation ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Add Calc', () => {
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
it('0830 + 0130 = 1000', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('add'); await sleep(10);
|
||||||
|
setInput('tim-add-time', '0830');
|
||||||
|
setInput('tim-add-dur', '0130');
|
||||||
|
expect($('#tim-add-result').textContent).toBe('1000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps around 24h (2330 + 0100 = 0030)', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('add'); await sleep(10);
|
||||||
|
setInput('tim-add-time', '2330');
|
||||||
|
setInput('tim-add-dur', '0100');
|
||||||
|
expect($('#tim-add-result').textContent).toBe('0030');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workday calculation ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Workday Calc', () => {
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
it('start 0800, break 1200→1245, target default 8h → 1645', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday'); await sleep(10);
|
||||||
|
setInput('tim-wd-start', '0800');
|
||||||
|
setInput('tim-wd-break-from', '1200');
|
||||||
|
setInput('tim-wd-break-back', '1245');
|
||||||
|
expect($('#tim-wd-result').textContent).toContain('End at 1645');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom target of 0400 → ends earlier', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday'); await sleep(10);
|
||||||
|
setInput('tim-wd-start', '0800');
|
||||||
|
setInput('tim-wd-break-from', '1000');
|
||||||
|
setInput('tim-wd-break-back', '1015');
|
||||||
|
setInput('tim-wd-target', '0400');
|
||||||
|
// 2h before break + 2h after = 4h; 10:15 + 2h = 1215
|
||||||
|
expect($('#tim-wd-result').textContent).toContain('End at 1215');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('break before start shows an error message', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday'); await sleep(10);
|
||||||
|
setInput('tim-wd-start', '1000');
|
||||||
|
setInput('tim-wd-break-from', '0900');
|
||||||
|
setInput('tim-wd-break-back', '0930');
|
||||||
|
expect($('#tim-wd-result').textContent).toContain('Break must be after start');
|
||||||
|
expect($('#tim-wd-result').classList.contains('err')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('target already reached before break → reports the finish time', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday'); await sleep(10);
|
||||||
|
setInput('tim-wd-start', '0800');
|
||||||
|
setInput('tim-wd-break-from', '1800'); // more than 8h before break
|
||||||
|
setInput('tim-wd-break-back', '1815');
|
||||||
|
setInput('tim-wd-target', '0800');
|
||||||
|
expect($('#tim-wd-result').textContent).toContain('Target already reached');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Focus selection + Tab cycling ──
|
||||||
|
|
||||||
|
describe('Tim Plugin: Focus & Tab Cycling', () => {
|
||||||
|
// Previous describes (Add Calc / Workday Calc) leave their tab active,
|
||||||
|
// which leaves the duration panel `.hidden` and breaks our Tab-cycle
|
||||||
|
// handler's closest('.tim-tab-body:not(.hidden)') lookup. Reset first.
|
||||||
|
beforeEach(() => {
|
||||||
|
document.querySelectorAll('#tim-overlay input').forEach(i => { i.value = ''; });
|
||||||
|
activateTab('duration');
|
||||||
|
});
|
||||||
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
|
// Spies HTMLInputElement.prototype.focus so we can detect which input
|
||||||
|
// the handler *tried* to focus without depending on document.activeElement
|
||||||
|
// (which is unreliable in some synthetic-event runs).
|
||||||
|
function withFocusSpy(fn) {
|
||||||
|
const orig = HTMLInputElement.prototype.focus;
|
||||||
|
let last = null;
|
||||||
|
HTMLInputElement.prototype.focus = function() { last = this; return orig.apply(this, arguments); };
|
||||||
|
try { return fn(() => last); } finally { HTMLInputElement.prototype.focus = orig; }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('focusing an input with value selects the text', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
const el = $('#tim-dur-start');
|
||||||
|
el.value = '0830';
|
||||||
|
el.focus();
|
||||||
|
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
expect(el.selectionStart).toBe(0);
|
||||||
|
expect(el.selectionEnd).toBe(el.value.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab from last input wraps to the first within the active panel', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
const first = $('#tim-dur-start');
|
||||||
|
const last = $('#tim-dur-end');
|
||||||
|
withFocusSpy(getLast => {
|
||||||
|
last.focus();
|
||||||
|
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||||
|
expect(getLast()).toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+Tab from first wraps to the last within the active panel', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
const first = $('#tim-dur-start');
|
||||||
|
const last = $('#tim-dur-end');
|
||||||
|
withFocusSpy(getLast => {
|
||||||
|
first.focus();
|
||||||
|
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
|
||||||
|
expect(getLast()).toBe(last);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tab cycling stays within the active tab (workday has 4 inputs)', async () => {
|
||||||
|
openTim(); await sleep(30);
|
||||||
|
activateTab('workday'); await sleep(10);
|
||||||
|
const inputs = [...document.querySelectorAll('[data-tim-panel="workday"] input')];
|
||||||
|
expect(inputs.length).toBe(4);
|
||||||
|
withFocusSpy(getLast => {
|
||||||
|
inputs[3].focus();
|
||||||
|
inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
||||||
|
expect(getLast()).toBe(inputs[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
|
|||||||
bodyEl.value = 'after edit';
|
bodyEl.value = 'after edit';
|
||||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
click('#modal-save-close');
|
click('#modal-save-close');
|
||||||
await sleep(400);
|
|
||||||
|
|
||||||
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
let card;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100);
|
||||||
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||||
|
if ((card?.body || '').includes('after edit')) break;
|
||||||
|
}
|
||||||
expect(card.body).toContain('after edit');
|
expect(card.body).toContain('after edit');
|
||||||
|
|
||||||
ctrlZ();
|
ctrlZ();
|
||||||
await sleep(400);
|
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100);
|
||||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||||
|
if ((card?.body || '').includes('before edit')) break;
|
||||||
|
}
|
||||||
expect(card.body).toContain('before edit');
|
expect(card.body).toContain('before edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +357,15 @@ describe('Undo: No-Op When Empty', () => {
|
|||||||
ctrlZ();
|
ctrlZ();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
// Now Ctrl+Z should be harmless
|
// Wait for state to settle — previous undo operations can still be
|
||||||
|
// resurrecting cards asynchronously after canUndo() has flipped to
|
||||||
|
// false. Poll until two consecutive fetches agree on card count.
|
||||||
|
let prev = -1, stable = 0;
|
||||||
|
for (let i = 0; i < 30 && stable < 2; i++) {
|
||||||
|
const cards = await fetch('api/cards').then(r => r.json());
|
||||||
|
if (cards.length === prev) stable++; else { stable = 0; prev = cards.length; }
|
||||||
|
if (stable < 2) await sleep(100);
|
||||||
|
}
|
||||||
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
||||||
ctrlZ();
|
ctrlZ();
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# tim plugin
|
||||||
|
|
||||||
|
Tiny time calculator. Three tabs: duration between two times, add a duration to a time, and workday-end with a break.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **`I`** on the board
|
||||||
|
- **Clock icon** in the card-edit toolbar
|
||||||
|
- Terminal: `tim`
|
||||||
|
|
||||||
|
Close with **ESC** or the × button.
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
### Duration
|
||||||
|
Two HHMM inputs → how many hours and minutes between them. Wraps past midnight.
|
||||||
|
|
||||||
|
### Add
|
||||||
|
Time + duration → resulting HHMM. Wraps around 24h.
|
||||||
|
|
||||||
|
### Workday
|
||||||
|
Start, break from, break back, target (defaults to 08:00). Computes when the workday ends to hit the target, or tells you it's already reached.
|
||||||
|
|
||||||
|
## Input format
|
||||||
|
|
||||||
|
All inputs take HHMM-style strings:
|
||||||
|
|
||||||
|
- `0830`, `830`, `08:30`, `8:30` → 08:30
|
||||||
|
- Missing digits are left-padded with zeros
|
||||||
|
- Minute component must be < 60
|
||||||
|
|
||||||
|
Invalid input clears the result silently — no red flashes while typing.
|
||||||
|
|
||||||
|
Calculation is live: the result updates as you type in each field.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"tim"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "tim",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Time calculator — duration between two times, add duration to time, end-of-day for target work hours.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["tim.js"],
|
||||||
|
"css": ["tim.css"],
|
||||||
|
"html": ["tim.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#tim-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
#tim-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
.tim-panel {
|
||||||
|
background: var(--t-surface); border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal, 4px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||||
|
width: 100%; max-width: 600px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tim-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--t-border);
|
||||||
|
}
|
||||||
|
.tim-header h2 { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 12px; color: var(--t-accent); margin: 0; }
|
||||||
|
.tim-spacer { flex: 1; }
|
||||||
|
.tim-btn-secondary {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px;
|
||||||
|
padding: 5px 12px; background: transparent;
|
||||||
|
border: 1px solid var(--t-border); color: var(--t-muted); cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.tim-btn-secondary:hover { border-color: var(--t-danger); color: var(--t-danger); }
|
||||||
|
|
||||||
|
.tim-tabs { display: flex; border-bottom: 1px solid var(--t-border); }
|
||||||
|
.tim-tab {
|
||||||
|
flex: 1; padding: 10px 16px; background: transparent; border: none;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px;
|
||||||
|
color: var(--t-muted); cursor: pointer; transition: all 0.15s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tim-tab:hover { color: var(--t-bright); }
|
||||||
|
.tim-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.tim-tab-body { padding: 20px; }
|
||||||
|
.tim-tab-body.hidden { display: none; }
|
||||||
|
|
||||||
|
.tim-hint {
|
||||||
|
font-size: 11px; color: var(--t-dim);
|
||||||
|
margin-bottom: 16px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tim-fields {
|
||||||
|
display: flex; justify-content: center; flex-wrap: wrap;
|
||||||
|
gap: 20px 28px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tim-field {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.tim-field label {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 9px;
|
||||||
|
color: var(--t-dim); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.tim-field input {
|
||||||
|
background: var(--t-bg); border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright); padding: 6px 10px;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
font-family: var(--t-font-content, monospace); font-size: 13px;
|
||||||
|
width: 110px; text-align: center; letter-spacing: 2px;
|
||||||
|
outline: none; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.tim-field input:focus { border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.tim-result {
|
||||||
|
margin-top: 16px; padding: 14px 16px;
|
||||||
|
border: 1px solid var(--t-border); border-radius: 4px;
|
||||||
|
font-family: var(--t-font-content, monospace); font-size: 16px;
|
||||||
|
color: var(--t-bright); text-align: center;
|
||||||
|
min-height: 48px; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--t-bg);
|
||||||
|
}
|
||||||
|
.tim-result.ok { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.tim-result.err { border-color: var(--t-danger); color: var(--t-danger); font-size: 11px; }
|
||||||
|
.tim-result:empty::before {
|
||||||
|
content: '—'; color: var(--t-dim); opacity: 0.3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<div id="tim-overlay" class="hidden">
|
||||||
|
<div class="tim-panel">
|
||||||
|
<div class="tim-header">
|
||||||
|
<i class="ph ph-clock text-t-accent"></i>
|
||||||
|
<h2>tim — time calculator</h2>
|
||||||
|
<span class="tim-spacer"></span>
|
||||||
|
<button id="tim-close" class="tim-btn-secondary"><i class="ph ph-x"></i> close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tim-tabs">
|
||||||
|
<button class="tim-tab active" data-tim-tab="duration">Duration</button>
|
||||||
|
<button class="tim-tab" data-tim-tab="add">Add</button>
|
||||||
|
<button class="tim-tab" data-tim-tab="workday">Workday</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tim-tab-body" data-tim-panel="duration">
|
||||||
|
<p class="tim-hint">Duration between two times. Format HHMM, e.g. 0830-1715.</p>
|
||||||
|
<div class="tim-fields">
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-dur-start">Start</label>
|
||||||
|
<input type="text" id="tim-dur-start" placeholder="0830" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-dur-end">End</label>
|
||||||
|
<input type="text" id="tim-dur-end" placeholder="1715" maxlength="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tim-result" id="tim-dur-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tim-tab-body hidden" data-tim-panel="add">
|
||||||
|
<p class="tim-hint">Add a duration to a given time.</p>
|
||||||
|
<div class="tim-fields">
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-add-time">Time</label>
|
||||||
|
<input type="text" id="tim-add-time" placeholder="0830" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-add-dur">Duration</label>
|
||||||
|
<input type="text" id="tim-add-dur" placeholder="0130" maxlength="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tim-result" id="tim-add-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tim-tab-body hidden" data-tim-panel="workday">
|
||||||
|
<p class="tim-hint">End time to reach target work duration, given start, break start, and break end.</p>
|
||||||
|
<div class="tim-fields">
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-wd-start">Start</label>
|
||||||
|
<input type="text" id="tim-wd-start" placeholder="0820" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-wd-break-from">Break from</label>
|
||||||
|
<input type="text" id="tim-wd-break-from" placeholder="1200" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-wd-break-back">Back from break</label>
|
||||||
|
<input type="text" id="tim-wd-break-back" placeholder="1245" maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="tim-field">
|
||||||
|
<label for="tim-wd-target">Target</label>
|
||||||
|
<input type="text" id="tim-wd-target" placeholder="0800" maxlength="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tim-result" id="tim-wd-result"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// ── tim — time calculator plugin ──
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const overlay = $('tim-overlay');
|
||||||
|
|
||||||
|
function show() { overlay?.classList.remove('hidden'); $('tim-dur-start')?.focus(); }
|
||||||
|
function hide() { overlay?.classList.add('hidden'); }
|
||||||
|
function toggle() { overlay?.classList.contains('hidden') ? show() : hide(); }
|
||||||
|
|
||||||
|
function pad4(n) { return String(n).padStart(4, '0'); }
|
||||||
|
|
||||||
|
// Parse HHMM string → total minutes since midnight, or null on invalid
|
||||||
|
function parseHHMM(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
s = String(s).trim().replace(':', '').padStart(4, '0');
|
||||||
|
if (!/^\d{3,4}$/.test(s)) return null;
|
||||||
|
const h = parseInt(s.slice(0, -2));
|
||||||
|
const m = parseInt(s.slice(-2));
|
||||||
|
if (m >= 60) return null;
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToHHMM(mins) {
|
||||||
|
mins = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
|
||||||
|
return pad4(Math.floor(mins / 60) * 100 + (mins % 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(mins) {
|
||||||
|
if (mins < 0) mins += 24 * 60;
|
||||||
|
const h = Math.floor(mins / 60), m = mins % 60;
|
||||||
|
return `${h}h ${String(m).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab switching ──
|
||||||
|
|
||||||
|
document.querySelectorAll('.tim-tab').forEach(t => {
|
||||||
|
t.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tim-tab').forEach(x => x.classList.toggle('active', x === t));
|
||||||
|
document.querySelectorAll('.tim-tab-body').forEach(p => {
|
||||||
|
p.classList.toggle('hidden', p.dataset.timPanel !== t.dataset.timTab);
|
||||||
|
});
|
||||||
|
const firstInput = document.querySelector(`[data-tim-panel="${t.dataset.timTab}"] input`);
|
||||||
|
firstInput?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Duration tab ──
|
||||||
|
|
||||||
|
function calcDuration() {
|
||||||
|
const res = $('tim-dur-result');
|
||||||
|
const s = parseHHMM($('tim-dur-start').value);
|
||||||
|
const e = parseHHMM($('tim-dur-end').value);
|
||||||
|
if (s == null || e == null) {
|
||||||
|
res.className = 'tim-result';
|
||||||
|
res.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diff = e - s;
|
||||||
|
res.className = 'tim-result ok';
|
||||||
|
res.textContent = formatDuration(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add tab ──
|
||||||
|
|
||||||
|
function calcAdd() {
|
||||||
|
const res = $('tim-add-result');
|
||||||
|
const t = parseHHMM($('tim-add-time').value);
|
||||||
|
const d = parseHHMM($('tim-add-dur').value);
|
||||||
|
if (t == null || d == null) {
|
||||||
|
res.className = 'tim-result';
|
||||||
|
res.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.className = 'tim-result ok';
|
||||||
|
res.textContent = minutesToHHMM(t + d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workday tab ──
|
||||||
|
|
||||||
|
function calcWorkday() {
|
||||||
|
const res = $('tim-wd-result');
|
||||||
|
const s = parseHHMM($('tim-wd-start').value);
|
||||||
|
const bf = parseHHMM($('tim-wd-break-from').value);
|
||||||
|
const bb = parseHHMM($('tim-wd-break-back').value);
|
||||||
|
const target = parseHHMM($('tim-wd-target').value) ?? 480;
|
||||||
|
|
||||||
|
if (s == null || bf == null || bb == null) {
|
||||||
|
res.className = 'tim-result';
|
||||||
|
res.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workedBeforeBreak = bf - s;
|
||||||
|
if (workedBeforeBreak < 0) {
|
||||||
|
res.className = 'tim-result err';
|
||||||
|
res.textContent = 'Break must be after start';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = target - workedBeforeBreak;
|
||||||
|
if (remaining < 0) {
|
||||||
|
res.className = 'tim-result ok';
|
||||||
|
res.textContent = 'Target already reached at ' + minutesToHHMM(s + target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endOfDay = (bb + remaining) % (24 * 60);
|
||||||
|
res.className = 'tim-result ok';
|
||||||
|
res.textContent = `End at ${minutesToHHMM(endOfDay)} (${formatDuration(remaining)} left after break)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single delegated input handler on the overlay — routes to the right
|
||||||
|
// calc by input id. Robust against DOM order / re-renders.
|
||||||
|
overlay?.addEventListener('input', e => {
|
||||||
|
const id = e.target?.id || '';
|
||||||
|
if (id.startsWith('tim-dur-')) calcDuration();
|
||||||
|
else if (id.startsWith('tim-add-')) calcAdd();
|
||||||
|
else if (id.startsWith('tim-wd-')) calcWorkday();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus → select existing text so typing replaces it.
|
||||||
|
overlay?.addEventListener('focusin', e => {
|
||||||
|
if (e.target.tagName === 'INPUT' && e.target.value) e.target.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab cycles within the currently active tab panel's inputs.
|
||||||
|
overlay?.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
const panel = e.target.closest('.tim-tab-body:not(.hidden)');
|
||||||
|
if (!panel) return;
|
||||||
|
const inputs = [...panel.querySelectorAll('input')];
|
||||||
|
if (!inputs.length) return;
|
||||||
|
const idx = inputs.indexOf(e.target);
|
||||||
|
if (idx === -1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const next = e.shiftKey
|
||||||
|
? (idx - 1 + inputs.length) % inputs.length
|
||||||
|
: (idx + 1) % inputs.length;
|
||||||
|
inputs[next].focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Global bindings ──
|
||||||
|
|
||||||
|
$('tim-close')?.addEventListener('click', hide);
|
||||||
|
overlay?.addEventListener('click', e => { if (e.target === overlay) hide(); });
|
||||||
|
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && !overlay?.classList.contains('hidden')) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
// Expose toggle for terminal / shortcuts
|
||||||
|
window._timToggle = toggle;
|
||||||
|
|
||||||
|
// Register toolbar command — the md-toolbar delegated click handler in
|
||||||
|
// editor.js routes clicks on [data-md="tim"] through this.
|
||||||
|
h?.editor?.registerCommand('tim', () => toggle());
|
||||||
|
|
||||||
|
function injectToolbarButton() {
|
||||||
|
const toolbar = document.getElementById('md-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
if (toolbar.querySelector('[data-md="tim"]')) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.setAttribute('data-md', 'tim');
|
||||||
|
btn.setAttribute('data-help', 'tim — time calculator\nDuration, add, workday end.\nShortcut: I');
|
||||||
|
btn.className = 'toolbar-btn';
|
||||||
|
btn.innerHTML = '<i class="ph ph-clock text-sm"></i>';
|
||||||
|
const spacer = toolbar.querySelector('.flex-1');
|
||||||
|
if (spacer) toolbar.insertBefore(btn, spacer);
|
||||||
|
else toolbar.appendChild(btn);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectToolbarButton);
|
||||||
|
} else {
|
||||||
|
injectToolbarButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
h?.registerPlugin({ name: 'tim', version: '1.0.0' });
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg
|
||||||
|
width="176.00125mm"
|
||||||
|
height="176.00124mm"
|
||||||
|
viewBox="0 0 176.00125 176.00124"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-16.173035,-17.825715)"><path
|
||||||
|
id="rect233"
|
||||||
|
style="display:inline;fill:var(--watch-svg-markers-fill, var(--watch-num-color, #fff));fill-opacity:1;stroke:none;stroke-width:0.19504;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
|
||||||
|
d="m 411.61352,-261.01455 c 1.3819,0 2.49444,1.11255 2.49444,2.49445 v 8.93128 c 0,1.38191 -1.11254,2.49445 -2.49444,2.49445 -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 v -8.93128 c 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m 53.93185,14.43191 c 0.40192,0.0109 0.80726,0.11988 1.18125,0.33581 1.19676,0.69095 1.60412,2.21084 0.91316,3.40761 l -4.46598,7.73467 c -0.69096,1.19677 -2.21015,1.60342 -3.40692,0.91246 -1.19677,-0.69095 -1.60411,-2.21015 -0.91316,-3.40691 l 4.46529,-7.73467 c 0.47503,-0.82278 1.34214,-1.27285 2.22636,-1.24897 z m -107.86371,0 c 0.88422,-0.0239 1.75063,0.42619 2.22566,1.24897 l 4.46599,7.73467 c 0.69095,1.19676 0.28361,2.71596 -0.91316,3.40691 -1.19676,0.69095 -2.71666,0.2843 -3.40761,-0.91246 l -4.46529,-7.73467 c -0.69096,-1.19677 -0.2843,-2.71666 0.91246,-3.40761 0.374,-0.21593 0.78004,-0.32495 1.18195,-0.33581 z m 147.16469,39.43293 c 0.88422,-0.0239 1.75063,0.42549 2.22566,1.24827 0.69095,1.19676 0.2843,2.71666 -0.91246,3.40761 l -7.73467,4.46599 c -1.19677,0.69095 -2.71667,0.28359 -3.40762,-0.91317 -0.69095,-1.19677 -0.28359,-2.71666 0.91317,-3.40761 l 7.73467,-4.46529 c 0.37399,-0.21592 0.77934,-0.32495 1.18125,-0.3358 z m -186.46637,0 c 0.40192,0.0109 0.80797,0.11988 1.18195,0.3358 l 7.73467,4.46529 c 1.19676,0.69095 1.60342,2.21084 0.91247,3.40761 -0.69095,1.19676 -2.21015,1.60412 -3.40691,0.91317 l -7.73467,-4.46599 c -1.19677,-0.69096 -1.60412,-2.21085 -0.91317,-3.40761 0.47504,-0.82279 1.34145,-1.27216 2.22566,-1.24827 z m 200.9653,53.86762 c 1.3819,0 2.49444,1.11255 2.49444,2.49445 0,1.38191 -1.11254,2.49445 -2.49444,2.49445 H 510.414 c -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m -206.53295,0 c 1.38191,0 2.49445,1.11255 2.49445,2.49445 0,1.38191 -1.11254,2.49445 -2.49445,2.49445 h -8.93127 c -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m 184.4306,49.39885 c 0.40192,0.0109 0.80796,0.11988 1.18195,0.3358 l 7.73467,4.465294 c 1.19676,0.690953 1.60341,2.210851 0.91246,3.407611 -0.69095,1.19677 -2.21015,1.604118 -3.40691,0.913165 l -7.73467,-4.465989 c -1.19677,-0.690952 -1.60412,-2.210151 -0.91317,-3.406911 0.47503,-0.82278 1.34145,-1.27286 2.22567,-1.24897 z m -171.25952,0 c 0.88421,-0.0239 1.75063,0.42619 2.22566,1.24897 0.69095,1.19676 0.2843,2.715956 -0.91247,3.406911 l -7.73467,4.465989 c -1.19676,0.69095 -2.71665,0.283605 -3.40761,-0.913165 -0.69095,-1.19676 -0.2836,-2.716656 0.91317,-3.407611 l 7.73467,-4.465294 c 0.37399,-0.21592 0.77933,-0.32495 1.18125,-0.3358 z m 134.96472,36.163545 c 0.88422,-0.02388 1.75064,0.426193 2.22567,1.248969 l 4.46598,7.73467 c 0.69096,1.196766 0.2836,2.716662 -0.91316,3.407612 -1.19677,0.690955 -2.71666,0.2836 -3.40761,-0.913165 l -4.46529,-7.73467 c -0.69095,-1.196766 -0.2836,-2.716661 0.91316,-3.407611 0.374,-0.215925 0.77933,-0.324948 1.18125,-0.335805 z m -98.66923,0 c 0.40192,0.01085 0.80726,0.11988 1.18125,0.335805 1.19676,0.69095 1.60411,2.21085 0.91316,3.407611 l -4.46599,7.73467 c -0.69095,1.196769 -2.21085,1.604115 -3.40761,0.913165 -1.19677,-0.690955 -1.60342,-2.210842 -0.91246,-3.407612 l 4.46529,-7.73467 c 0.47503,-0.822776 1.34215,-1.272853 2.22636,-1.248969 z m 49.33462,13.238092 c 1.3819,0 2.49444,1.112541 2.49444,2.494447 v 8.931279 c 0,1.381905 -1.11254,2.494447 -2.49444,2.494447 -1.38191,0 -2.49445,-1.112542 -2.49445,-2.494447 v -8.931279 c 0,-1.381906 1.11254,-2.494447 2.49445,-2.494447 z"
|
||||||
|
transform="matrix(-0.74020373,0,0,0.74020373,408.85134,217.42417)" /><circle
|
||||||
|
style="fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle256"
|
||||||
|
cx="104.17366"
|
||||||
|
cy="105.82634"
|
||||||
|
r="88.000626" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg
|
||||||
|
width="176.00125mm"
|
||||||
|
height="176.00124mm"
|
||||||
|
viewBox="0 0 176.00125 176.00124"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-16.173035,-17.825715)"><path
|
||||||
|
id="rect256"
|
||||||
|
style="display:inline;fill:var(--watch-svg-hour-fill, var(--watch-hand-color, #fff));fill-opacity:1;stroke-width:0.809242;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
d="m 104.17365,63.017641 c 4.52102,0 8.16069,3.639666 8.16069,8.160687 v 34.700732 c 0,4.52102 -3.63967,8.16069 -8.16069,8.16069 -4.521021,0 -8.160688,-3.63967 -8.160688,-8.16069 V 71.178328 c 0,-4.521021 3.639667,-8.160687 8.160688,-8.160687 z" /><circle
|
||||||
|
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle256"
|
||||||
|
cx="104.17366"
|
||||||
|
cy="105.82634"
|
||||||
|
r="88.000626" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg
|
||||||
|
width="176.00125mm"
|
||||||
|
height="176.00124mm"
|
||||||
|
viewBox="0 0 176.00125 176.00124"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-16.173035,-17.825715)"><path
|
||||||
|
id="rect231"
|
||||||
|
style="display:inline;fill:var(--watch-svg-minute-fill, var(--watch-hand-color, #fff));stroke-width:0.809242;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
d="m 104.17365,40.792641 c 4.52102,0 8.16069,3.639666 8.16069,8.160687 v 56.925732 c 0,4.52102 -3.63967,8.16069 -8.16069,8.16069 -4.521021,0 -8.160688,-3.63967 -8.160688,-8.16069 V 48.953328 c 0,-4.521021 3.639667,-8.160687 8.160688,-8.160687 z" /><circle
|
||||||
|
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle256"
|
||||||
|
cx="104.17366"
|
||||||
|
cy="105.82634"
|
||||||
|
r="88.000626" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 1013 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg
|
||||||
|
width="176.00125mm"
|
||||||
|
height="176.00124mm"
|
||||||
|
viewBox="0 0 176.00125 176.00124"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-16.173035,-17.825715)"><rect
|
||||||
|
style="display:inline;fill:var(--watch-svg-second-fill, var(--watch-second-color, #fff));fill-opacity:1;stroke:none;stroke-width:0.144369;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
|
||||||
|
id="rect237"
|
||||||
|
width="3.69275"
|
||||||
|
height="10.303658"
|
||||||
|
x="-106.02003"
|
||||||
|
y="24.220301"
|
||||||
|
ry="1.846375"
|
||||||
|
transform="scale(-1,1)" /><circle
|
||||||
|
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle256"
|
||||||
|
cx="104.17366"
|
||||||
|
cy="105.82634"
|
||||||
|
r="88.000626" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 972 B |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "watch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Analog clock overlay. Shows after 5 min idle or press W.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["watch.js"],
|
||||||
|
"css": ["watch.css"],
|
||||||
|
"html": ["watch.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* ── Watch overlay ── */
|
||||||
|
|
||||||
|
#watch-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9000;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
cursor: pointer; overflow: hidden;
|
||||||
|
opacity: 0; transform: scale(0.985);
|
||||||
|
transition: opacity 220ms ease, transform 220ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#watch-overlay.visible {
|
||||||
|
opacity: 1; transform: scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
#watch-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
#watch-darkening {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: #000; opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
#watch-analog {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: min(78vmin, 720px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
#watch-dial {
|
||||||
|
position: relative; width: 100%; height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.watch-layer {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.watch-layer svg { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#watch-layer-digits { z-index: 1; filter: drop-shadow(0 0 12px rgba(0,0,0,0.8)); }
|
||||||
|
#watch-layer-hour { z-index: 3; }
|
||||||
|
#watch-layer-minute { z-index: 2; }
|
||||||
|
#watch-layer-second { z-index: 4; }
|
||||||
|
|
||||||
|
#watch-second-wobble {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform 92ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme (default): white hands/markers */
|
||||||
|
#watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #fff;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #68c6ed);
|
||||||
|
--watch-svg-minute-fill: #fff;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #68c6ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme: black hands/markers */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
#watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #111;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #2563eb);
|
||||||
|
--watch-svg-minute-fill: #111;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #2563eb);
|
||||||
|
}
|
||||||
|
#watch-darkening { background: #fff; opacity: 0.85; }
|
||||||
|
#watch-layer-digits { filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Also detect hyprcrd's own theme mode via body class / data attribute */
|
||||||
|
body.light-theme #watch-overlay,
|
||||||
|
[data-theme-mode="light"] #watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #111;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #2563eb);
|
||||||
|
--watch-svg-minute-fill: #111;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #2563eb);
|
||||||
|
}
|
||||||
|
body.light-theme #watch-darkening,
|
||||||
|
[data-theme-mode="light"] #watch-darkening {
|
||||||
|
background: #fff; opacity: 0.85;
|
||||||
|
}
|
||||||
|
body.light-theme #watch-layer-digits,
|
||||||
|
[data-theme-mode="light"] #watch-layer-digits {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#watch-overlay { transition: none !important; }
|
||||||
|
#watch-second-wobble { transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div id="watch-overlay" class="hidden" aria-hidden="true">
|
||||||
|
<div id="watch-darkening"></div>
|
||||||
|
<div id="watch-analog">
|
||||||
|
<div id="watch-dial">
|
||||||
|
<div id="watch-layer-digits" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-hour" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-minute" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-second" class="watch-layer">
|
||||||
|
<div id="watch-second-wobble"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// ── Watch plugin — analog clock overlay ──
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const LAYER_MAP = {
|
||||||
|
digits: 'watch-layer-digits',
|
||||||
|
hour: 'watch-layer-hour',
|
||||||
|
minute: 'watch-layer-minute',
|
||||||
|
second: 'watch-second-wobble',
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible = false;
|
||||||
|
let tickTimer = null;
|
||||||
|
let idleTimer = null;
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
let showedAt = 0;
|
||||||
|
let wobbleKick = null, wobbleRebound = null;
|
||||||
|
let svgCache = {};
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
const IDLE_MINUTES = 5;
|
||||||
|
|
||||||
|
// ── SVG loading ──
|
||||||
|
|
||||||
|
async function loadSvg(layer) {
|
||||||
|
if (svgCache[layer]) return svgCache[layer];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`);
|
||||||
|
if (!res.ok) return '';
|
||||||
|
let svg = await res.text();
|
||||||
|
svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(/<!DOCTYPE[\s\S]*?>/gi, '').trim();
|
||||||
|
svgCache[layer] = svg;
|
||||||
|
return svg;
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFace() {
|
||||||
|
if (loaded) return;
|
||||||
|
for (const [layer, elId] of Object.entries(LAYER_MAP)) {
|
||||||
|
const el = $(elId);
|
||||||
|
if (el) el.innerHTML = await loadSvg(layer);
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clock logic ──
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = new Date();
|
||||||
|
const h12 = now.getHours() % 12;
|
||||||
|
const m = now.getMinutes();
|
||||||
|
const s = now.getSeconds();
|
||||||
|
|
||||||
|
const hourEl = $('watch-layer-hour');
|
||||||
|
const minEl = $('watch-layer-minute');
|
||||||
|
const secEl = $('watch-layer-second');
|
||||||
|
|
||||||
|
if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`;
|
||||||
|
if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`;
|
||||||
|
if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`;
|
||||||
|
|
||||||
|
triggerWobble();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerWobble() {
|
||||||
|
const wobEl = $('watch-second-wobble');
|
||||||
|
if (!wobEl) return;
|
||||||
|
clearTimeout(wobbleKick);
|
||||||
|
clearTimeout(wobbleRebound);
|
||||||
|
wobEl.style.transform = 'rotate(0deg)';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
wobEl.style.transform = 'rotate(0.78deg)';
|
||||||
|
wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95);
|
||||||
|
wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTick() {
|
||||||
|
if (tickTimer) return;
|
||||||
|
tick();
|
||||||
|
tickTimer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTick() {
|
||||||
|
if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
|
||||||
|
clearTimeout(wobbleKick);
|
||||||
|
clearTimeout(wobbleRebound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show / hide ──
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
if (visible) return;
|
||||||
|
visible = true;
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
showedAt = Date.now();
|
||||||
|
await loadFace();
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
startTick();
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (!visible) return;
|
||||||
|
visible = false;
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
stopTick();
|
||||||
|
setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() { visible ? hide() : show(); }
|
||||||
|
|
||||||
|
// ── Idle auto-show ──
|
||||||
|
|
||||||
|
function resetIdleTimer() {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
if (!IDLE_MINUTES) return;
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
if (visible) return;
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
if (document.querySelector('.overlay.open')) return;
|
||||||
|
show();
|
||||||
|
}, IDLE_MINUTES * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivity() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastActivity < 300) return;
|
||||||
|
lastActivity = now;
|
||||||
|
if (visible && now - showedAt > 500) hide();
|
||||||
|
resetIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
overlay.addEventListener('click', () => { hide(); onActivity(); });
|
||||||
|
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && visible) {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
hide(); onActivity();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) {
|
||||||
|
window.addEventListener(ev, onActivity, { passive: true });
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') onActivity();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetIdleTimer();
|
||||||
|
window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
window._watchToggle = toggle;
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
+15
-12
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
||||||
share one relay instance; each one has their own login, their own client
|
share one relay instance; each one has their own login, their own client
|
||||||
encryption secret, and their own inbox. Cards (and in the future:
|
encryption secret, and their own inbox. Cards and encrypted chat
|
||||||
encrypted chat messages) can be sent **between tenants** on the same
|
messages can be sent **between tenants** on the same relay.
|
||||||
relay.
|
|
||||||
|
|
||||||
The relay is a single-file PHP app — drop the directory into any PHP
|
The relay is a single-file PHP app — drop the directory into any PHP
|
||||||
webspace and it works.
|
webspace and it works.
|
||||||
@@ -55,9 +54,9 @@ The admin only ever touches secrets #1 and #3. The user only ever touches secret
|
|||||||
|
|
||||||
The relay **does** see plaintext briefly during the cross-tenant hop
|
The relay **does** see plaintext briefly during the cross-tenant hop
|
||||||
(steps 2–3 and 5–6). This is the same trust model as email: you trust
|
(steps 2–3 and 5–6). This is the same trust model as email: you trust
|
||||||
the relay operator. **Strict end-to-end encryption** for the planned
|
the relay operator. Chat between tenants is shipped (via the chat
|
||||||
chat feature is on the roadmap and will require a per-pair key exchange
|
plugin with delivery + read receipts). **Strict end-to-end encryption**
|
||||||
(Curve25519/ECDH) — this is documented as future work in `index.php`.
|
with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
|
||||||
|
|
||||||
For at-rest data, all sensitive values are encrypted with the master
|
For at-rest data, all sensitive values are encrypted with the master
|
||||||
key, so a stolen DB without the master key file gives an attacker
|
key, so a stolen DB without the master key file gives an attacker
|
||||||
@@ -155,7 +154,7 @@ incoming messages.
|
|||||||
|
|
||||||
### 6. Send a card to another tenant
|
### 6. Send a card to another tenant
|
||||||
|
|
||||||
(currently in the relay UI; hyprcrd-side recipient picker is coming)
|
(in the relay UI compose tab)
|
||||||
|
|
||||||
In the relay UI **compose** tab:
|
In the relay UI **compose** tab:
|
||||||
- Pick a recipient from the dropdown (defaults to yourself)
|
- Pick a recipient from the dropdown (defaults to yourself)
|
||||||
@@ -166,8 +165,7 @@ The recipient will see it on their next hyprcrd poll, materialized as a
|
|||||||
card in their board.
|
card in their board.
|
||||||
|
|
||||||
You can also right-click a card in hyprcrd and pick **push to relay** —
|
You can also right-click a card in hyprcrd and pick **push to relay** —
|
||||||
this currently sends to your own inbox; recipient picker on the hyprcrd
|
this sends to your own inbox (or pick a recipient in the relay UI).
|
||||||
side is on the TODO.
|
|
||||||
|
|
||||||
## Data lifetime
|
## Data lifetime
|
||||||
|
|
||||||
@@ -226,6 +224,10 @@ All endpoints under `/api/`. JSON in/out.
|
|||||||
| GET | `/api/me` | — | Current tenant info |
|
| GET | `/api/me` | — | Current tenant info |
|
||||||
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
|
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
|
||||||
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
|
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
|
||||||
|
| GET | `/api/messages/inbox` | — | Read own inbox messages (decrypted for display) |
|
||||||
|
| DELETE | `/api/messages/{id}` | — | Delete a message from own inbox |
|
||||||
|
| GET | `/api/chat/sent` | — | Sent chat messages with delivery/read status |
|
||||||
|
| POST | `/api/chat/seen` | `{ids: [...]}` | Mark chat messages as seen (read receipts) |
|
||||||
|
|
||||||
### Admin only (session + `is_admin`)
|
### Admin only (session + `is_admin`)
|
||||||
|
|
||||||
@@ -302,8 +304,7 @@ use the same code path.
|
|||||||
- A compromised relay machine = compromised messages. Use HTTPS, keep
|
- A compromised relay machine = compromised messages. Use HTTPS, keep
|
||||||
PHP up to date, restrict shell access.
|
PHP up to date, restrict shell access.
|
||||||
|
|
||||||
**Future work for stronger guarantees** (planned for the encrypted chat
|
**Future work for stronger guarantees** (roadmap for strict E2E):
|
||||||
feature):
|
|
||||||
- Per-tenant Curve25519 key pair stored in the DB (public key plus
|
- Per-tenant Curve25519 key pair stored in the DB (public key plus
|
||||||
master-key-encrypted private key, or even private key never on the
|
master-key-encrypted private key, or even private key never on the
|
||||||
server)
|
server)
|
||||||
@@ -522,7 +523,9 @@ relay/
|
|||||||
│ └── routes/
|
│ └── routes/
|
||||||
│ ├── public.php # /api/status, /setup, /login, /logout, /me
|
│ ├── public.php # /api/status, /setup, /login, /logout, /me
|
||||||
│ ├── admin.php # /api/users CRUD, /api/tenants directory
|
│ ├── admin.php # /api/users CRUD, /api/tenants directory
|
||||||
│ └── messages.php # /api/messages, /api/attachments, cross-tenant routing
|
│ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
|
||||||
|
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
|
||||||
|
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── layout.php # HTML shell + view containers
|
│ ├── layout.php # HTML shell + view containers
|
||||||
│ ├── style.css # all CSS (including responsive media queries)
|
│ ├── style.css # all CSS (including responsive media queries)
|
||||||
|
|||||||
@@ -189,8 +189,31 @@ $chatColNames = array_column($chatCols, 'name');
|
|||||||
if (!in_array('seen', $chatColNames)) {
|
if (!in_array('seen', $chatColNames)) {
|
||||||
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
|
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
|
||||||
|
// the chat thread. The feature moved to its own `relay_sync_events` queue
|
||||||
|
// (see below). Drop any leftover bot rows so they stop showing up in
|
||||||
|
// users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs
|
||||||
|
// a full table rebuild — not worth it for an ignored nullable column).
|
||||||
|
if (in_array('bot', $chatColNames)) {
|
||||||
|
$db->exec('DELETE FROM chat_messages WHERE bot = 1');
|
||||||
|
}
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
|
||||||
|
|
||||||
|
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
||||||
|
// client syncs a card they received, they POST here; the sender polls
|
||||||
|
// GET /api/sync-events and surfaces each as a transient toast. Rows are
|
||||||
|
// short-lived — we drop fetched ones after an hour, unfetched after a day.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS relay_sync_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipient_id INTEGER NOT NULL,
|
||||||
|
sender_username TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
fetched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_sync_events_recipient ON relay_sync_events(recipient_id, id)');
|
||||||
|
|
||||||
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
// Collaborative buckets: shared among multiple tenants. Each bucket has one
|
||||||
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
// owner and a membership set. Buckets persist (not ephemeral) since they are
|
||||||
// the container for ongoing collaboration — cleanup only when deleted.
|
// the container for ongoing collaboration — cleanup only when deleted.
|
||||||
@@ -232,6 +255,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
|
|||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
||||||
|
|
||||||
|
// Collab attachments: binary assets referenced by collab cards (images,
|
||||||
|
// PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to
|
||||||
|
// a bucket — all members of the bucket can fetch. Filename is the
|
||||||
|
// hex-hash name that appears in card bodies as uploads/<name>.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (bucket_uid, filename)
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)');
|
||||||
|
|
||||||
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
||||||
// editor for a collab card heartbeats into this table every 30s with a
|
// editor for a collab card heartbeats into this table every 30s with a
|
||||||
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
||||||
@@ -280,6 +317,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"
|
|||||||
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
||||||
// Chat: 7 days, longer than card sync but still ephemeral
|
// Chat: 7 days, longer than card sync but still ephemeral
|
||||||
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
|
||||||
|
// Sync events are transient toasts — drop fetched ones after an hour,
|
||||||
|
// everything after 24h regardless (sender was offline too long to care).
|
||||||
|
$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')");
|
||||||
|
$db->exec("DELETE FROM relay_sync_events WHERE created_at < datetime('now', '-24 hours')");
|
||||||
// Collab-card tombstones: 7 days so late pollers still see the delete.
|
// Collab-card tombstones: 7 days so late pollers still see the delete.
|
||||||
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
|
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
|
||||||
// Collab-card locks: drop everything past its TTL on every request.
|
// Collab-card locks: drop everything past its TTL on every request.
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|||||||
if ($id === (int)$sessionTenant['id']) {
|
if ($id === (int)$sessionTenant['id']) {
|
||||||
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
||||||
}
|
}
|
||||||
|
// chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
|
||||||
|
// SET NULL — that leaves sender_username populated so the deleted user
|
||||||
|
// keeps showing up as a chat thread for their peers. Purge those rows
|
||||||
|
// explicitly BEFORE the tenant delete, while sender_id is still matchable.
|
||||||
|
$db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
|
||||||
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-2
@@ -17,10 +17,143 @@
|
|||||||
|
|
||||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
// $clientTenant is set by messages.php earlier in the chain via authClient().
|
// ── Session-based chat (relay UI) ──
|
||||||
// We re-resolve here to keep this file self-contained.
|
|
||||||
|
if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$myId = $sessionTenant['id'];
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
||||||
|
FROM chat_messages WHERE recipient_id = :r
|
||||||
|
GROUP BY sender_username
|
||||||
|
UNION
|
||||||
|
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
||||||
|
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
||||||
|
WHERE cm.sender_id = :s AND cm.recipient_id != :s2
|
||||||
|
GROUP BY t.username
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$threads = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$peer = $row['peer'];
|
||||||
|
if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0];
|
||||||
|
$threads[$peer]['count'] += (int)$row['count'];
|
||||||
|
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
||||||
|
}
|
||||||
|
usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
|
||||||
|
// Add unread count per peer
|
||||||
|
foreach ($threads as &$t) {
|
||||||
|
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
|
||||||
|
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
||||||
|
$t['unread'] = (int)$stmt2->fetchColumn();
|
||||||
|
}
|
||||||
|
jsonResponse(array_values($threads));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$peer = trim($_GET['peer'] ?? '');
|
||||||
|
if (!$peer) jsonResponse(['error' => 'peer required'], 400);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$myId = $sessionTenant['id'];
|
||||||
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
|
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
|
||||||
|
$peerId = $peerTenant['id'];
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_id, sender_username, body, created_at, seen
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
||||||
|
ORDER BY created_at ASC LIMIT 500
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'from' => $row['sender_username'],
|
||||||
|
'mine' => (int)$row['sender_id'] === $myId,
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'seen' => (int)$row['seen'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Mark incoming as seen
|
||||||
|
$db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0')
|
||||||
|
->execute(['r' => $myId, 'p' => $peerId]);
|
||||||
|
jsonResponse($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stored = clientEncrypt($body, $interKey);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
||||||
|
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
||||||
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client API ──
|
||||||
|
|
||||||
$clientTenant = $clientTenant ?? authClient($db);
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
// Sync-event notifications. Client API only: a tenant that just synced
|
||||||
|
// a received card POSTs here to announce it to the original sender, and
|
||||||
|
// the sender polls GET to surface each event as a transient toast.
|
||||||
|
// Bodies are short plaintext strings like "alice synced: Card Title" —
|
||||||
|
// no encryption, no per-peer routing through the chat thread, no
|
||||||
|
// clutter in the regular chat history.
|
||||||
|
|
||||||
|
if ($route === '/api/sync-events' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||||
|
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||||
|
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
|
||||||
|
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
|
||||||
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/sync-events' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_username, body, created_at
|
||||||
|
FROM relay_sync_events
|
||||||
|
WHERE recipient_id = :r AND fetched = 0
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 100
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $clientTenant['id']]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if ($rows) {
|
||||||
|
$maxId = (int)$rows[count($rows) - 1]['id'];
|
||||||
|
$db->prepare('UPDATE relay_sync_events SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
|
||||||
|
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
||||||
|
}
|
||||||
|
$out = array_map(fn($r) => [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'sender_username' => $r['sender_username'],
|
||||||
|
'body' => $r['body'],
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
], $rows);
|
||||||
|
jsonResponse(['events' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if ($route === '/api/chat' && $method === 'POST') {
|
if ($route === '/api/chat' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
|||||||
@@ -105,6 +105,47 @@ if ($route === '/api/collab-cards' && $method === 'POST') {
|
|||||||
|
|
||||||
// ── GET: pull deltas since a given timestamp ─────────────────────
|
// ── GET: pull deltas since a given timestamp ─────────────────────
|
||||||
|
|
||||||
|
// ── Session-auth summary for the relay UI ─────────────────────
|
||||||
|
// Lets the relay's web UI surface collab bucket activity to the
|
||||||
|
// logged-in tenant without exposing the per-client encryption dance:
|
||||||
|
// decrypts payloads with the inter-tenant key (we're authorizing via
|
||||||
|
// the PHP session, so there's no per-request client secret). Returns
|
||||||
|
// just title + small body preview + author + bucket so the UI can list
|
||||||
|
// recent activity like an inbox.
|
||||||
|
if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted,
|
||||||
|
b.name AS bucket_name, b.color AS bucket_color
|
||||||
|
FROM collab_cards c
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
|
||||||
|
LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid
|
||||||
|
WHERE m.tenant_id = :tid AND c.deleted = 0
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $sessionTenant['id']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
|
||||||
|
$fields = $plain ? (json_decode($plain, true) ?: []) : [];
|
||||||
|
$body = (string)($fields['body'] ?? '');
|
||||||
|
$out[] = [
|
||||||
|
'bucket_uid' => $row['bucket_uid'],
|
||||||
|
'bucket_name' => $row['bucket_name'],
|
||||||
|
'bucket_color' => $row['bucket_color'] ?? '#38bdf8',
|
||||||
|
'card_uid' => $row['card_uid'],
|
||||||
|
'author_username' => $row['author_username'],
|
||||||
|
'title' => (string)($fields['title'] ?? ''),
|
||||||
|
'preview' => mb_substr(trim(strip_tags($body)), 0, 140),
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
'mine' => $row['author_username'] === $sessionTenant['username'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'items' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/collab-cards' && $method === 'GET') {
|
if ($route === '/api/collab-cards' && $method === 'GET') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
@@ -199,3 +240,66 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
|
|||||||
]);
|
]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketUid = (string)($input['bucket_uid'] ?? '');
|
||||||
|
$filename = (string)($input['filename'] ?? '');
|
||||||
|
$data = (string)($input['data'] ?? '');
|
||||||
|
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt-reencrypt hop: data arrives encrypted with sender's secret,
|
||||||
|
// stored at rest under the inter-tenant key.
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'sender has no client secret'], 400);
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($data, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO collab_attachments (bucket_uid, filename, data) VALUES (:b, :f, :d)
|
||||||
|
ON CONFLICT(bucket_uid, filename) DO UPDATE SET data = excluded.data')
|
||||||
|
->execute(['b' => $bucketUid, 'f' => $filename, 'd' => $stored]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$bucketUid = trim($_GET['bucket_uid'] ?? '');
|
||||||
|
$filename = trim($_GET['filename'] ?? '');
|
||||||
|
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'no client secret set'], 400);
|
||||||
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
if ($filename) {
|
||||||
|
$stmt = $db->prepare('SELECT filename, data FROM collab_attachments WHERE bucket_uid = :b AND filename = :f');
|
||||||
|
$stmt->execute(['b' => $bucketUid, 'f' => $filename]);
|
||||||
|
} else {
|
||||||
|
// List all filenames for this bucket (no data — client picks what to fetch)
|
||||||
|
$stmt = $db->prepare('SELECT filename FROM collab_attachments WHERE bucket_uid = :b ORDER BY created_at DESC');
|
||||||
|
$stmt->execute(['b' => $bucketUid]);
|
||||||
|
jsonResponse(['ok' => true, 'files' => array_column($stmt->fetchAll(), 'filename')]);
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$plain = clientDecrypt($row['data'] ?? '', $interKey);
|
||||||
|
$out[] = ['filename' => $row['filename'], 'data' => clientEncrypt($plain, $recipientSecret)];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'attachments' => $out]);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$storedTitle = clientEncrypt($title, $interKey);
|
$storedTitle = clientEncrypt($title, $interKey);
|
||||||
$storedBody = clientEncrypt($body, $interKey);
|
$storedBody = clientEncrypt($body, $interKey);
|
||||||
|
$relayUid = $input['relay_uid'] ?? bin2hex(random_bytes(16));
|
||||||
|
|
||||||
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
|
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
|
||||||
->execute([
|
->execute([
|
||||||
@@ -37,9 +38,43 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
'b' => $storedBody,
|
'b' => $storedBody,
|
||||||
'c' => $input['color'] ?? '#38bdf8',
|
'c' => $input['color'] ?? '#38bdf8',
|
||||||
'tg' => $input['tags'] ?? '',
|
'tg' => $input['tags'] ?? '',
|
||||||
'uid' => $input['relay_uid'] ?? null,
|
'uid' => $relayUid,
|
||||||
]);
|
]);
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName, 'relay_uid' => $relayUid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI attachment upload: session stores files encrypted at rest ──
|
||||||
|
|
||||||
|
if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant) {
|
||||||
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
jsonResponse(['error' => 'no file'], 400);
|
||||||
|
}
|
||||||
|
$relayUid = $_POST['relay_uid'] ?? '';
|
||||||
|
$recipientName = trim($_POST['recipient'] ?? $sessionTenant['username']);
|
||||||
|
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
||||||
|
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
||||||
|
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
// Generate hex filename so hyprcrd's poll handler accepts it
|
||||||
|
$origName = basename($_FILES['file']['name']);
|
||||||
|
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
|
||||||
|
$hexName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
|
||||||
|
|
||||||
|
$plainBytes = file_get_contents($_FILES['file']['tmp_name']);
|
||||||
|
// Store as base64(raw) encrypted — matches client API format.
|
||||||
|
// The GET endpoint re-encrypts for recipient; hyprcrd decrypts
|
||||||
|
// and base64_decodes to get the original file bytes.
|
||||||
|
$storedData = clientEncrypt(base64_encode($plainBytes), $interKey);
|
||||||
|
|
||||||
|
$db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
|
||||||
|
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName]);
|
||||||
|
$db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
|
||||||
|
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName, 'data' => $storedData]);
|
||||||
|
jsonResponse(['ok' => true, 'filename' => $hexName, 'original' => $origName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UI inbox view: session reads decrypted messages ──
|
// ── UI inbox view: session reads decrypted messages ──
|
||||||
@@ -71,6 +106,28 @@ if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
|||||||
jsonResponse(['messages' => $out]);
|
jsonResponse(['messages' => $out]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UI sent view: messages this user sent to others ──
|
||||||
|
|
||||||
|
if ($route === '/api/messages/sent' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('SELECT m.id, m.title, m.body, m.created_at, m.fetched, t.username AS recipient FROM messages m JOIN tenants t ON t.id = m.recipient_id WHERE m.sender_id = :s AND m.recipient_id != :s2 ORDER BY m.created_at DESC LIMIT 200');
|
||||||
|
$stmt->execute(['s' => $sessionTenant['id'], 's2' => $sessionTenant['id']]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'recipient' => $row['recipient'] ?? '?',
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'fetched' => (int)$row['fetched'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['messages' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||||
|
|
||||||
$clientTenant = authClient($db);
|
$clientTenant = authClient($db);
|
||||||
@@ -155,8 +212,8 @@ if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|||||||
$tenant = $clientTenant ?: $sessionTenant;
|
$tenant = $clientTenant ?: $sessionTenant;
|
||||||
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$id = (int)$m[1];
|
$id = (int)$m[1];
|
||||||
$db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
|
$db->prepare('DELETE FROM messages WHERE id = :id AND (recipient_id = :r OR sender_id = :s)')
|
||||||
->execute(['id' => $id, 'r' => $tenant['id']]);
|
->execute(['id' => $id, 'r' => $tenant['id'], 's' => $tenant['id']]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+354
-12
@@ -68,14 +68,15 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
name = 'change-password';
|
||||||
}
|
}
|
||||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
$('view-' + name)?.classList.remove('hidden');
|
$('view-' + name)?.classList.remove('hidden');
|
||||||
|
|
||||||
if (['compose', 'account', 'admin'].includes(name)) {
|
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
|
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||||
$('nav-burger')?.classList.remove('hidden');
|
$('nav-burger')?.classList.remove('hidden');
|
||||||
@@ -91,11 +92,19 @@ function showView(name) {
|
|||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
loadInbox();
|
loadInbox();
|
||||||
|
loadOutbox();
|
||||||
|
loadCollabInbox();
|
||||||
startInboxPoll();
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
$('compose-body')?.focus();
|
||||||
} else {
|
} else {
|
||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
}
|
}
|
||||||
|
if (name === 'chat') {
|
||||||
|
loadChatThreads();
|
||||||
|
startChatPoll();
|
||||||
|
} else {
|
||||||
|
stopChatPoll();
|
||||||
|
}
|
||||||
if (name === 'admin') {
|
if (name === 'admin') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (name === 'account') {
|
} else if (name === 'account') {
|
||||||
@@ -113,16 +122,30 @@ function showView(name) {
|
|||||||
// the moment the user navigates away (account/admin/login) so we don't
|
// the moment the user navigates away (account/admin/login) so we don't
|
||||||
// hammer the server in the background.
|
// hammer the server in the background.
|
||||||
let inboxPollHandle = null;
|
let inboxPollHandle = null;
|
||||||
|
let inboxBusy = false;
|
||||||
function startInboxPoll() {
|
function startInboxPoll() {
|
||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
inboxPollHandle = setInterval(() => {
|
inboxPollHandle = setInterval(() => {
|
||||||
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
|
if (inboxBusy) return;
|
||||||
|
if (!$('view-compose')?.classList.contains('hidden')) {
|
||||||
|
loadInbox({ quiet: true });
|
||||||
|
loadOutbox({ quiet: true });
|
||||||
|
loadCollabInbox({ quiet: true });
|
||||||
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
}
|
}
|
||||||
function stopInboxPoll() {
|
function stopInboxPoll() {
|
||||||
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppress poll re-renders while the user is typing in any input/textarea
|
||||||
|
document.addEventListener('focusin', e => {
|
||||||
|
if (e.target.matches('input, textarea, select')) inboxBusy = true;
|
||||||
|
});
|
||||||
|
document.addEventListener('focusout', e => {
|
||||||
|
if (e.target.matches('input, textarea, select')) inboxBusy = false;
|
||||||
|
});
|
||||||
|
|
||||||
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
||||||
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
||||||
* positioned under the header. */
|
* positioned under the header. */
|
||||||
@@ -323,6 +346,7 @@ function populateRecipientPicker() {
|
|||||||
hidden.value = opt.dataset.value;
|
hidden.value = opt.dataset.value;
|
||||||
label.textContent = opt.textContent.trim();
|
label.textContent = opt.textContent.trim();
|
||||||
dd.classList.add('hidden');
|
dd.classList.add('hidden');
|
||||||
|
updateCcSelfVisibility();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,6 +359,17 @@ function populateRecipientPicker() {
|
|||||||
|
|
||||||
// Solo tenant → hide the picker
|
// Solo tenant → hide the picker
|
||||||
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
|
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
|
||||||
|
updateCcSelfVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
let composeFiles = [];
|
||||||
|
|
||||||
|
function updateCcSelfVisibility() {
|
||||||
|
const wrap = $('compose-cc-self-wrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
const recipient = $('compose-recipient')?.value || '';
|
||||||
|
const isSelf = !recipient || (me && recipient === me.username);
|
||||||
|
wrap.style.display = isSelf ? 'none' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCompose() {
|
function clearCompose() {
|
||||||
@@ -342,27 +377,140 @@ function clearCompose() {
|
|||||||
$('compose-body').value = '';
|
$('compose-body').value = '';
|
||||||
$('compose-body').focus();
|
$('compose-body').focus();
|
||||||
setComposeStatus('');
|
setComposeStatus('');
|
||||||
|
composeFiles = [];
|
||||||
|
renderComposeAttachments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click());
|
||||||
|
$('compose-file')?.addEventListener('change', e => {
|
||||||
|
for (const f of e.target.files) {
|
||||||
|
if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue;
|
||||||
|
composeFiles.push(f);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
renderComposeAttachments();
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(name) {
|
||||||
|
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image';
|
||||||
|
if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note';
|
||||||
|
if (/\.pdf$/i.test(name)) return 'ph-file-pdf';
|
||||||
|
return 'ph-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComposeAttachments() {
|
||||||
|
const el = $('compose-attachments');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = composeFiles.map((f, i) => `
|
||||||
|
<div class="compose-att">
|
||||||
|
<i class="ph ${fileIcon(f.name)} att-icon"></i>
|
||||||
|
<span>${escapeHtml(f.name)}</span>
|
||||||
|
<span class="att-size">${formatSize(f.size)}</span>
|
||||||
|
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
el.querySelectorAll('.att-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
composeFiles.splice(parseInt(btn.dataset.idx), 1);
|
||||||
|
renderComposeAttachments();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let composeStatusTimer = null;
|
||||||
function setComposeStatus(text, kind = '') {
|
function setComposeStatus(text, kind = '') {
|
||||||
|
clearTimeout(composeStatusTimer);
|
||||||
const el = $('compose-status');
|
const el = $('compose-status');
|
||||||
|
if (!el) return;
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
el.className = 'status ' + kind;
|
el.className = 'compose-status-bar' + (kind ? ' ' + kind : '');
|
||||||
|
if (text) {
|
||||||
|
el.classList.add('visible');
|
||||||
|
if (kind === 'ok') {
|
||||||
|
composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
el.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(n) {
|
||||||
|
const a = new Uint8Array(n);
|
||||||
|
crypto.getRandomValues(a);
|
||||||
|
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCompose() {
|
async function sendCompose() {
|
||||||
const title = $('compose-title').value;
|
const title = $('compose-title').value;
|
||||||
const body = $('compose-body').value;
|
let body = $('compose-body').value;
|
||||||
const recipient = $('compose-recipient').value;
|
const recipient = $('compose-recipient').value;
|
||||||
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
|
if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; }
|
||||||
|
|
||||||
|
const relayUid = randomHex(16);
|
||||||
|
const hasFiles = composeFiles.length > 0;
|
||||||
|
|
||||||
|
if (hasFiles) {
|
||||||
|
setComposeStatus('uploading files...');
|
||||||
|
const hexNames = [];
|
||||||
|
for (const f of composeFiles) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', f);
|
||||||
|
form.append('relay_uid', relayUid);
|
||||||
|
form.append('recipient', recipient || '');
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/attachments/upload', { method: 'POST', body: form });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok && data.filename) {
|
||||||
|
const ext = (data.original || f.name).split('.').pop().toLowerCase();
|
||||||
|
const isImg = /^(png|jpe?g|gif|webp)$/.test(ext);
|
||||||
|
const label = data.original || f.name;
|
||||||
|
hexNames.push(isImg
|
||||||
|
? ``
|
||||||
|
: `[📄 ${label}](uploads/${data.filename})`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (hexNames.length) {
|
||||||
|
body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setComposeStatus('sending...');
|
setComposeStatus('sending...');
|
||||||
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
|
const ccSelf = $('compose-cc-self')?.checked && recipient;
|
||||||
|
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setComposeStatus(`sent to ${res.recipient}`, 'ok');
|
// Send a copy to self tagged with recipient name
|
||||||
|
if (ccSelf) {
|
||||||
|
const ccTag = `#sent/${res.recipient}`;
|
||||||
|
const ccBody = body ? body + '\n\n' + ccTag : ccTag;
|
||||||
|
const ccUid = randomHex(16);
|
||||||
|
await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } });
|
||||||
|
// Also copy attachments to the self-message
|
||||||
|
if (hasFiles) {
|
||||||
|
for (const f of composeFiles) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', f);
|
||||||
|
form.append('relay_uid', ccUid);
|
||||||
|
form.append('recipient', '');
|
||||||
|
try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok');
|
||||||
$('compose-title').value = '';
|
$('compose-title').value = '';
|
||||||
$('compose-body').value = '';
|
$('compose-body').value = '';
|
||||||
|
$('compose-cc-self').checked = false;
|
||||||
|
composeFiles = [];
|
||||||
|
renderComposeAttachments();
|
||||||
$('compose-body').focus();
|
$('compose-body').focus();
|
||||||
loadInbox();
|
loadInbox();
|
||||||
|
loadOutbox();
|
||||||
refreshMe();
|
refreshMe();
|
||||||
} else {
|
} else {
|
||||||
setComposeStatus(res.error || 'failed', 'err');
|
setComposeStatus(res.error || 'failed', 'err');
|
||||||
@@ -371,18 +519,27 @@ async function sendCompose() {
|
|||||||
|
|
||||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||||
|
|
||||||
async function loadInbox() {
|
let lastInboxJson = '';
|
||||||
|
|
||||||
|
async function loadInbox({ quiet = false } = {}) {
|
||||||
const list = $('inbox-list');
|
const list = $('inbox-list');
|
||||||
|
if (!quiet) {
|
||||||
list.innerHTML = `
|
list.innerHTML = `
|
||||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
<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 short"></div></div>
|
||||||
<div class="skeleton-row"><div class="skel"></div></div>
|
<div class="skeleton-row"><div class="skel"></div></div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
const res = await api('api/messages/inbox');
|
const res = await api('api/messages/inbox');
|
||||||
const messages = res?.messages || [];
|
const messages = res?.messages || [];
|
||||||
|
|
||||||
|
// In quiet mode, skip DOM update if nothing changed
|
||||||
|
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
|
||||||
|
if (quiet && json === lastInboxJson) return;
|
||||||
|
lastInboxJson = json;
|
||||||
|
|
||||||
$('inbox-count').textContent = messages.length;
|
$('inbox-count').textContent = messages.length;
|
||||||
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
$('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').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||||
|
|
||||||
@@ -407,7 +564,79 @@ async function loadInbox() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteMessage(id, btn) {
|
let lastOutboxJson = '';
|
||||||
|
|
||||||
|
async function loadOutbox({ quiet = false } = {}) {
|
||||||
|
const list = $('outbox-list');
|
||||||
|
if (!list) return;
|
||||||
|
const res = await api('api/messages/sent');
|
||||||
|
const messages = res?.messages || [];
|
||||||
|
|
||||||
|
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
|
||||||
|
if (quiet && json === lastOutboxJson) return;
|
||||||
|
lastOutboxJson = json;
|
||||||
|
|
||||||
|
$('outbox-count').textContent = messages.length;
|
||||||
|
|
||||||
|
if (!messages.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-paper-plane-tilt icon"></i>
|
||||||
|
<div class="label">no sent messages</div>
|
||||||
|
<div class="hint">Messages you send to other tenants will appear 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-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</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, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared buckets / collab inbox ──
|
||||||
|
let lastCollabJson = '';
|
||||||
|
async function loadCollabInbox({ quiet = false } = {}) {
|
||||||
|
const list = $('shared-list');
|
||||||
|
const countEl = $('shared-count');
|
||||||
|
if (!list) return;
|
||||||
|
const res = await api('api/collab-inbox');
|
||||||
|
const items = res?.items || [];
|
||||||
|
const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at));
|
||||||
|
if (quiet && json === lastCollabJson) return;
|
||||||
|
lastCollabJson = json;
|
||||||
|
if (countEl) countEl.textContent = items.length;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-users-three icon"></i>
|
||||||
|
<div class="label">no shared bucket activity</div>
|
||||||
|
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(c => `
|
||||||
|
<div class="inbox-item">
|
||||||
|
<div class="content">
|
||||||
|
<div class="from">
|
||||||
|
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
|
||||||
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
||||||
|
${c.preview ? `<div style="color:var(--dim);font-size:11px;margin-top:2px;">${escapeHtml(c.preview)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(id, btn, source) {
|
||||||
|
inboxBusy = true;
|
||||||
// Inline confirmation: replace the trash button with yes/no.
|
// Inline confirmation: replace the trash button with yes/no.
|
||||||
// Event listeners are wired via JS (not inline onclick) to avoid
|
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||||
// passing escaped HTML through attributes.
|
// passing escaped HTML through attributes.
|
||||||
@@ -425,9 +654,10 @@ async function deleteMessage(id, btn) {
|
|||||||
btn.replaceWith(wrap);
|
btn.replaceWith(wrap);
|
||||||
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
wrap.querySelector('.del-yes').addEventListener('click', async () => {
|
||||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||||
|
inboxBusy = false;
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
|
||||||
loadInbox();
|
if (source === 'outbox') loadOutbox(); else loadInbox();
|
||||||
refreshMe();
|
refreshMe();
|
||||||
toast('message deleted', 'ok');
|
toast('message deleted', 'ok');
|
||||||
} else {
|
} else {
|
||||||
@@ -435,6 +665,7 @@ async function deleteMessage(id, btn) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
wrap.querySelector('.del-no').addEventListener('click', () => {
|
wrap.querySelector('.del-no').addEventListener('click', () => {
|
||||||
|
inboxBusy = false;
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = origHtml;
|
temp.innerHTML = origHtml;
|
||||||
wrap.replaceWith(temp.firstChild);
|
wrap.replaceWith(temp.firstChild);
|
||||||
@@ -443,6 +674,8 @@ async function deleteMessage(id, btn) {
|
|||||||
window.deleteMessage = deleteMessage;
|
window.deleteMessage = deleteMessage;
|
||||||
|
|
||||||
async function deleteAllMessages() {
|
async function deleteAllMessages() {
|
||||||
|
inboxBusy = true;
|
||||||
|
try {
|
||||||
const res = await api('api/messages/inbox');
|
const res = await api('api/messages/inbox');
|
||||||
const messages = res?.messages || [];
|
const messages = res?.messages || [];
|
||||||
if (!messages.length) return;
|
if (!messages.length) return;
|
||||||
@@ -455,12 +688,121 @@ async function deleteAllMessages() {
|
|||||||
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
||||||
loadInbox();
|
loadInbox();
|
||||||
refreshMe();
|
refreshMe();
|
||||||
|
} finally { inboxBusy = false; }
|
||||||
}
|
}
|
||||||
window.deleteAllMessages = deleteAllMessages;
|
window.deleteAllMessages = deleteAllMessages;
|
||||||
|
|
||||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||||
* or a full date for older messages. */
|
* or a full date for older messages. */
|
||||||
|
// ── Chat ──
|
||||||
|
|
||||||
|
let chatActivePeer = null;
|
||||||
|
let chatPollHandle = null;
|
||||||
|
|
||||||
|
function startChatPoll() {
|
||||||
|
stopChatPoll();
|
||||||
|
chatPollHandle = setInterval(() => {
|
||||||
|
loadChatThreads(true);
|
||||||
|
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
function stopChatPoll() {
|
||||||
|
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatThreads(quiet = false) {
|
||||||
|
const list = $('chat-thread-list');
|
||||||
|
if (!list) return;
|
||||||
|
const threads = await api('api/chat/threads') || [];
|
||||||
|
if (!Array.isArray(threads)) return;
|
||||||
|
|
||||||
|
// If no threads and not quiet, show empty state
|
||||||
|
if (!threads.length && !quiet) {
|
||||||
|
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!threads.length) return;
|
||||||
|
|
||||||
|
list.innerHTML = threads.map(t => `
|
||||||
|
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
|
||||||
|
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="peer-name">${escapeHtml(t.peer)}</div>
|
||||||
|
<div class="thread-time">${formatRelTime(t.last_at)}</div>
|
||||||
|
</div>
|
||||||
|
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.chat-thread').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
chatActivePeer = el.dataset.peer;
|
||||||
|
loadChatThreads(true);
|
||||||
|
loadChatHistory(chatActivePeer);
|
||||||
|
$('chat-input-bar')?.classList.remove('hidden');
|
||||||
|
$('chat-peer-name').textContent = chatActivePeer;
|
||||||
|
$('chat-input')?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatHistory(peer, quiet = false) {
|
||||||
|
const container = $('chat-messages');
|
||||||
|
if (!container) return;
|
||||||
|
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
|
||||||
|
if (!Array.isArray(messages)) return;
|
||||||
|
|
||||||
|
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
||||||
|
|
||||||
|
if (!messages.length) {
|
||||||
|
container.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = messages.map(m => `
|
||||||
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||||
|
<div>${escapeHtml(m.body)}</div>
|
||||||
|
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
if (wasAtBottom || !quiet) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh thread list to clear unread badges
|
||||||
|
if (!quiet) loadChatThreads(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage() {
|
||||||
|
const input = $('chat-input');
|
||||||
|
if (!input || !chatActivePeer) return;
|
||||||
|
const body = input.value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
input.value = '';
|
||||||
|
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
|
||||||
|
loadChatHistory(chatActivePeer);
|
||||||
|
loadChatThreads(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('chat-send-btn')?.addEventListener('click', sendChatMessage);
|
||||||
|
$('chat-input')?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatChatTime(sqliteUtc) {
|
||||||
|
if (!sqliteUtc) return '';
|
||||||
|
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(t.getTime())) return sqliteUtc;
|
||||||
|
const now = new Date();
|
||||||
|
const time = t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (t.toDateString() === now.toDateString()) return time;
|
||||||
|
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (t.toDateString() === yesterday.toDateString()) return 'yesterday ' + time;
|
||||||
|
return t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelTime(sqliteUtc) {
|
function formatRelTime(sqliteUtc) {
|
||||||
if (!sqliteUtc) return '';
|
if (!sqliteUtc) return '';
|
||||||
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||||
|
|||||||
+40
-1
@@ -22,6 +22,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="nav-buttons" class="hidden">
|
<div id="nav-buttons" class="hidden">
|
||||||
<span id="nav-user" class="user"></span>
|
<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-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||||
|
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||||
@@ -91,13 +92,20 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<label>body</label>
|
<label>body</label>
|
||||||
<textarea id="compose-body" placeholder="write something..."></textarea>
|
<textarea id="compose-body" placeholder="write something..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="compose-attachments" class="compose-attachments"></div>
|
||||||
|
<label class="checkbox" id="compose-cc-self-wrap" style="margin-top:4px;">
|
||||||
|
<input type="checkbox" id="compose-cc-self"> keep a copy (tagged with recipient name)
|
||||||
|
</label>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
|
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
|
||||||
|
<button id="compose-attach-btn" class="btn btn-secondary"><i class="ph ph-paperclip"></i> attach</button>
|
||||||
|
<input type="file" id="compose-file" multiple class="hidden">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span id="compose-status" class="status"></span>
|
|
||||||
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
|
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="compose-status" class="compose-status-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="compose-sidebar" class="compose-sidebar">
|
||||||
<div id="inbox-section" class="section">
|
<div id="inbox-section" class="section">
|
||||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
@@ -105,6 +113,15 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="inbox-list"></div>
|
<div id="inbox-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="shared-section" class="section">
|
||||||
|
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
|
||||||
|
<div id="shared-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="outbox-section" class="section">
|
||||||
|
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
||||||
|
<div id="outbox-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Forced password change (first login) ── -->
|
<!-- ── Forced password change (first login) ── -->
|
||||||
@@ -193,6 +210,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chat view ── -->
|
||||||
|
<div id="view-chat" class="hidden">
|
||||||
|
<div class="chat-layout">
|
||||||
|
<div class="chat-sidebar">
|
||||||
|
<div class="chat-sidebar-header">
|
||||||
|
<span class="pixel" style="font-size:10px;color:var(--accent);">conversations</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-thread-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-main">
|
||||||
|
<div id="chat-header" class="chat-header">
|
||||||
|
<span id="chat-peer-name" class="pixel" style="font-size:11px;color:var(--bright);">select a conversation</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
|
<div id="chat-input-bar" class="chat-input-bar hidden">
|
||||||
|
<input type="text" id="chat-input" placeholder="type a message..." autocomplete="off">
|
||||||
|
<button id="chat-send-btn" class="btn btn-primary" style="padding:6px 14px;"><i class="ph ph-paper-plane-tilt"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Confirm dialog (replaces native confirm()) -->
|
<!-- Confirm dialog (replaces native confirm()) -->
|
||||||
|
|||||||
+91
-1
@@ -232,10 +232,78 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
|||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: flex; flex-direction: column; gap: 16px;
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
}
|
}
|
||||||
|
.compose-attachments {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||||
|
}
|
||||||
|
.compose-attachments:empty { display: none; }
|
||||||
|
.compose-att {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
padding: 4px 10px; border-radius: var(--radius);
|
||||||
|
background: var(--raised); border: 1px solid var(--border);
|
||||||
|
font-family: var(--pixel); font-size: 9px; color: var(--text);
|
||||||
|
}
|
||||||
|
.compose-att .att-remove {
|
||||||
|
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||||
|
padding: 0; font-size: 11px; line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.compose-att .att-remove:hover { color: var(--danger); }
|
||||||
|
.compose-att .att-icon { font-size: 12px; color: var(--dim); }
|
||||||
|
.compose-att .att-size { color: var(--dim); }
|
||||||
.compose-card .actions {
|
.compose-card .actions {
|
||||||
display:flex; gap:8px; align-items:center;
|
display:flex; gap:8px; align-items:center;
|
||||||
}
|
}
|
||||||
.compose-card .actions .spacer { flex:1; }
|
.compose-card .actions .spacer { flex:1; }
|
||||||
|
/* Chat view */
|
||||||
|
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
||||||
|
.chat-sidebar-header { padding: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
#chat-thread-list { flex: 1; overflow-y: auto; }
|
||||||
|
.chat-thread {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
|
||||||
|
cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.chat-thread:hover { background: var(--raised); }
|
||||||
|
.chat-thread.active { background: var(--raised); border-left: 3px solid var(--accent); }
|
||||||
|
.chat-thread .peer-name { font-family: var(--pixel); font-size: 10px; color: var(--bright); flex: 1; }
|
||||||
|
.chat-thread .unread-badge {
|
||||||
|
font-family: var(--pixel); font-size: 8px; background: var(--accent);
|
||||||
|
color: var(--bg); padding: 1px 6px; border-radius: 8px; min-width: 16px; text-align: center;
|
||||||
|
}
|
||||||
|
.chat-thread .thread-time { font-family: var(--pixel); font-size: 8px; color: var(--dim); }
|
||||||
|
.chat-main { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.chat-header { padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.chat-msg {
|
||||||
|
max-width: 75%; padding: 8px 12px; border-radius: var(--radius-lg);
|
||||||
|
font-size: 12px; line-height: 1.6; word-break: break-word;
|
||||||
|
}
|
||||||
|
.chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; }
|
||||||
|
.chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
|
||||||
|
.chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
|
||||||
|
.chat-msg.mine .msg-time { text-align: right; }
|
||||||
|
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.chat-input-bar input {
|
||||||
|
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--bright);
|
||||||
|
padding: 8px 12px; font-size: 12px; font-family: var(--mono);
|
||||||
|
border-radius: var(--radius); outline: none;
|
||||||
|
}
|
||||||
|
.chat-input-bar input:focus { border-color: var(--accent); }
|
||||||
|
.chat-empty {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: 8px; color: var(--dim);
|
||||||
|
font-family: var(--pixel); font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-status-bar {
|
||||||
|
font-family: var(--pixel); font-size: 10px; color: var(--muted);
|
||||||
|
min-height: 0; overflow: hidden;
|
||||||
|
transition: opacity 0.3s ease, max-height 0.3s ease;
|
||||||
|
max-height: 0; opacity: 0;
|
||||||
|
}
|
||||||
|
.compose-status-bar.visible { max-height: 32px; opacity: 1; padding-top: 8px; }
|
||||||
|
.compose-status-bar.ok { color: var(--accent); }
|
||||||
|
.compose-status-bar.err { color: var(--danger); }
|
||||||
|
|
||||||
/* Status text (used in compose, account, create-user) */
|
/* Status text (used in compose, account, create-user) */
|
||||||
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||||
@@ -564,6 +632,22 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
}
|
}
|
||||||
#view-admin .section h2 { margin-bottom:10px; }
|
#view-admin .section h2 { margin-bottom:10px; }
|
||||||
#view-admin .section .create-user-form { margin-top:14px; }
|
#view-admin .section .create-user-form { margin-top:14px; }
|
||||||
|
|
||||||
|
.chat-layout { flex-direction: column; height: calc(100vh - 160px); }
|
||||||
|
.chat-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
|
||||||
|
#chat-thread-list {
|
||||||
|
display: flex; overflow-x: auto; overflow-y: hidden; gap: 0;
|
||||||
|
scrollbar-width: none; -ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
#chat-thread-list::-webkit-scrollbar { display: none; }
|
||||||
|
.chat-thread {
|
||||||
|
flex-shrink: 0; flex-direction: column; align-items: center;
|
||||||
|
gap: 2px; padding: 8px 14px; border-bottom: none; border-right: 1px solid var(--border);
|
||||||
|
min-width: 70px; text-align: center;
|
||||||
|
}
|
||||||
|
.chat-thread.active { border-left: none; border-bottom: 3px solid var(--accent); }
|
||||||
|
.chat-thread .thread-time { display: none; }
|
||||||
|
.chat-sidebar-header { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
@@ -603,13 +687,19 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compose view: 2-column grid */
|
/* Compose view: 2 columns. Left is the compose editor. Right is a
|
||||||
|
sidebar with inbox → shared → sent stacked vertically. */
|
||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
#compose-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cards: depth via shadow + larger padding */
|
/* Cards: depth via shadow + larger padding */
|
||||||
.compose-card,
|
.compose-card,
|
||||||
|
|||||||
+48
-3
@@ -171,6 +171,12 @@ class CalDavSync
|
|||||||
$time = $zm[1];
|
$time = $zm[1];
|
||||||
$timeEnd = $zm[2] ?? '';
|
$timeEnd = $zm[2] ?? '';
|
||||||
}
|
}
|
||||||
|
// Multi-day: **Bis:** 2026-04-20 or **Bis:** 2026-04-20 18:00
|
||||||
|
$endDate = ''; $endTime = '';
|
||||||
|
if (preg_match('/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?/i', $body, $bm)) {
|
||||||
|
$endDate = $bm[1];
|
||||||
|
$endTime = $bm[2] ?? '';
|
||||||
|
}
|
||||||
$location = '';
|
$location = '';
|
||||||
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
||||||
$description = '';
|
$description = '';
|
||||||
@@ -178,9 +184,19 @@ class CalDavSync
|
|||||||
|
|
||||||
// Build DTSTART/DTEND
|
// Build DTSTART/DTEND
|
||||||
$dateCompact = str_replace('-', '', $date);
|
$dateCompact = str_replace('-', '', $date);
|
||||||
|
$endDateCompact = $endDate ? str_replace('-', '', $endDate) : '';
|
||||||
if ($time) {
|
if ($time) {
|
||||||
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
||||||
if ($timeEnd) {
|
if ($endDate) {
|
||||||
|
// Multi-day with time — use end date + end time (or start time + 1h)
|
||||||
|
$useEndTime = $endTime ?: $time;
|
||||||
|
if (!$endTime) {
|
||||||
|
$ts = strtotime($endDate . ' ' . $useEndTime);
|
||||||
|
$dtend = date('Ymd\THis', $ts + 3600);
|
||||||
|
} else {
|
||||||
|
$dtend = $endDateCompact . 'T' . str_replace(':', '', $endTime) . '00';
|
||||||
|
}
|
||||||
|
} elseif ($timeEnd) {
|
||||||
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
||||||
} else {
|
} else {
|
||||||
// Default: +1 hour
|
// Default: +1 hour
|
||||||
@@ -189,9 +205,14 @@ class CalDavSync
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$dtstart = $dateCompact;
|
$dtstart = $dateCompact;
|
||||||
// All-day: DTEND = next day
|
if ($endDate) {
|
||||||
|
// Multi-day all-day: DTEND is exclusive, so +1 day after end
|
||||||
|
$dtend = date('Ymd', strtotime($endDate . ' +1 day'));
|
||||||
|
} else {
|
||||||
|
// Single-day all-day: DTEND = next day
|
||||||
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
||||||
|
|
||||||
@@ -224,12 +245,22 @@ class CalDavSync
|
|||||||
|
|
||||||
$time = '';
|
$time = '';
|
||||||
$timeEnd = '';
|
$timeEnd = '';
|
||||||
|
$endDate = '';
|
||||||
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
||||||
$time = "{$hm[1]}:{$hm[2]}";
|
$time = "{$hm[1]}:{$hm[2]}";
|
||||||
}
|
}
|
||||||
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
||||||
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
||||||
}
|
}
|
||||||
|
// Detect multi-day event: dtend's date > dtstart's date
|
||||||
|
if (!empty($vevent['dtend']) && preg_match('/^(\d{4})(\d{2})(\d{2})/', $vevent['dtend'], $edm)) {
|
||||||
|
$endIso = "{$edm[1]}-{$edm[2]}-{$edm[3]}";
|
||||||
|
// For all-day events, dtend is exclusive — subtract one day
|
||||||
|
if (!$time && !empty($vevent['dtend']) && strpos($vevent['dtend'], 'T') === false) {
|
||||||
|
$endIso = date('Y-m-d', strtotime($endIso . ' -1 day'));
|
||||||
|
}
|
||||||
|
if ($endIso > $date) $endDate = $endIso;
|
||||||
|
}
|
||||||
|
|
||||||
$summary = $vevent['summary'] ?? 'Termin';
|
$summary = $vevent['summary'] ?? 'Termin';
|
||||||
$title = $summary ? "{$date} — {$summary}" : $date;
|
$title = $summary ? "{$date} — {$summary}" : $date;
|
||||||
@@ -244,7 +275,8 @@ class CalDavSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
||||||
$body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
$bisLine = $endDate ? ("\n**Bis:** " . $endDate . ($timeEnd && $endDate ? ' ' . $timeEnd : '')) : '';
|
||||||
|
$body = "**Zeit:** {$zeitLine}{$bisLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
||||||
|
|
||||||
// Color: birthdays pink, others orange
|
// Color: birthdays pink, others orange
|
||||||
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
||||||
@@ -381,11 +413,16 @@ class CalDavSync
|
|||||||
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
|
||||||
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
|
||||||
$remoteEvents = [];
|
$remoteEvents = [];
|
||||||
|
$fetchFailed = false;
|
||||||
foreach ($urls['all'] as $calUrl) {
|
foreach ($urls['all'] as $calUrl) {
|
||||||
$calSlug = basename(rtrim($calUrl, '/'));
|
$calSlug = basename(rtrim($calUrl, '/'));
|
||||||
$cal = $this->makeClient($calUrl);
|
$cal = $this->makeClient($calUrl);
|
||||||
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
|
||||||
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
|
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
|
||||||
|
if ($res['status'] != 207) {
|
||||||
|
$fetchFailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$events = $cal->listEvents();
|
$events = $cal->listEvents();
|
||||||
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
|
||||||
$parseFail = 0;
|
$parseFail = 0;
|
||||||
@@ -403,6 +440,14 @@ class CalDavSync
|
|||||||
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
|
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort on fetch failure — an incomplete remote list would make every
|
||||||
|
// local card look remotely-deleted and wipe them all. Better to sync
|
||||||
|
// nothing than to destroy local data on a flaky connection.
|
||||||
|
if ($fetchFailed) {
|
||||||
|
$this->addLog('sync', null, null, 'abort', 'error', 'remote fetch failed — skipping reconciliation to protect local data');
|
||||||
|
return $this->stats;
|
||||||
|
}
|
||||||
|
|
||||||
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
|
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
|
||||||
|
|
||||||
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
// 3. Fetch local appointment cards — only #termin tagged or already synced
|
||||||
|
|||||||
@@ -151,5 +151,23 @@ class Database
|
|||||||
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
|
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
|
||||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!in_array('scratchpad', $colNames)) {
|
||||||
|
self::$pdo->exec("ALTER TABLE cards ADD COLUMN scratchpad TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card edit history
|
||||||
|
self::$pdo->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS card_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
card_id INTEGER NOT NULL,
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>hyprcrd</title>
|
||||||
|
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #06080f;
|
||||||
|
--s1: #0d1117;
|
||||||
|
--s2: #161b22;
|
||||||
|
--bdr: #21262d;
|
||||||
|
--tx: #8b949e;
|
||||||
|
--hi: #e6edf3;
|
||||||
|
--ac: #38bdf8;
|
||||||
|
--ac2: #a78bfa;
|
||||||
|
--rd: #fb7185;
|
||||||
|
--gn: #34d399;
|
||||||
|
--yl: #fbbf24;
|
||||||
|
--pk: #f472b6;
|
||||||
|
--pixel: 'Silkscreen', monospace;
|
||||||
|
--mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { font-family: var(--mono); background: var(--bg); color: var(--tx); }
|
||||||
|
::selection { background: rgba(56,189,248,0.25); }
|
||||||
|
|
||||||
|
/* ── Top bar ── */
|
||||||
|
header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
background: rgba(6,8,15,0.88); backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--bdr); padding: 0 48px;
|
||||||
|
}
|
||||||
|
header .inner {
|
||||||
|
display: flex; align-items: center; height: 48px; gap: 20px;
|
||||||
|
}
|
||||||
|
header .brand { font-family: var(--pixel); font-size: 12px; color: var(--ac); letter-spacing: 2px; }
|
||||||
|
header nav { margin-left: auto; display: flex; gap: 20px; align-items: center; }
|
||||||
|
header nav a { font-size: 11px; color: var(--tx); font-family: var(--pixel); text-decoration: none; }
|
||||||
|
header nav a:hover { color: var(--hi); }
|
||||||
|
.gh-btn {
|
||||||
|
font-family: var(--pixel); font-size: 10px; color: var(--bg);
|
||||||
|
background: var(--ac); padding: 5px 16px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.gh-btn:hover { background: #7dd3fc; }
|
||||||
|
|
||||||
|
/* ── Sections ── */
|
||||||
|
.section {
|
||||||
|
min-height: 100vh; padding: 80px 48px;
|
||||||
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
|
border-bottom: 1px solid var(--bdr);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section label — big watermark word ── */
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
|
||||||
|
color: var(--bdr); letter-spacing: 4px; text-transform: uppercase;
|
||||||
|
line-height: 1; margin-bottom: 48px; user-select: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.section-label .accent { color: rgba(56,189,248,0.15); }
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero { padding-top: 100px; }
|
||||||
|
.hero-brand {
|
||||||
|
display: flex; align-items: center; gap: clamp(16px, 3vw, 32px);
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.hero-brand i {
|
||||||
|
font-size: clamp(60px, 12vw, 140px); color: var(--ac);
|
||||||
|
line-height: 1; display: block;
|
||||||
|
}
|
||||||
|
.hero-brand span {
|
||||||
|
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
|
||||||
|
color: var(--ac); letter-spacing: 4px; line-height: 1;
|
||||||
|
}
|
||||||
|
.hero-brand .typed { display: inline; }
|
||||||
|
.hero-brand .cursor {
|
||||||
|
display: inline-block; width: 0.55em; height: 1em;
|
||||||
|
background: var(--ac); vertical-align: text-bottom;
|
||||||
|
margin-left: 2px;
|
||||||
|
animation: cursor-blink 530ms steps(1) infinite;
|
||||||
|
}
|
||||||
|
.cursor-done { animation: cursor-blink 530ms steps(1) 3 forwards !important; }
|
||||||
|
@keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(48px, 8vw, 100px); font-weight: 700;
|
||||||
|
color: var(--hi); line-height: 1.05; margin-bottom: 32px; letter-spacing: -3px;
|
||||||
|
}
|
||||||
|
.hero-title .accent { color: var(--ac); }
|
||||||
|
.hero-title .dim { color: var(--bdr); }
|
||||||
|
.hero-sub { font-size: 16px; line-height: 1.8; max-width: 560px; margin-bottom: 48px; }
|
||||||
|
.hero-terminal {
|
||||||
|
background: var(--s1); border: 1px solid var(--bdr); border-radius: 8px;
|
||||||
|
padding: 20px 28px; font-size: 13px; line-height: 2.2; display: inline-block;
|
||||||
|
}
|
||||||
|
.hero-terminal .p { color: var(--gn); }
|
||||||
|
.hero-terminal .c { color: var(--hi); }
|
||||||
|
.hero-terminal .d { opacity: 0.3; }
|
||||||
|
|
||||||
|
/* ── Feature layout ── */
|
||||||
|
.feat {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 80px;
|
||||||
|
width: 100%; align-items: center;
|
||||||
|
}
|
||||||
|
.feat.reverse .feat-text { order: 2; }
|
||||||
|
.feat.reverse .feat-visual { order: 1; }
|
||||||
|
|
||||||
|
.feat-text h3 {
|
||||||
|
font-size: 28px; color: var(--hi); font-weight: 700;
|
||||||
|
line-height: 1.3; margin-bottom: 20px; letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
.feat-text p { font-size: 14px; line-height: 1.9; margin-bottom: 16px; }
|
||||||
|
.feat-text ul { list-style: none; margin-top: 16px; }
|
||||||
|
.feat-text li {
|
||||||
|
font-size: 13px; padding: 6px 0; display: flex; align-items: flex-start; gap: 10px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.feat-text li::before {
|
||||||
|
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--ac); flex-shrink: 0; margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat-visual {
|
||||||
|
background: var(--s1); border: 1px solid var(--bdr); border-radius: 10px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Visual panels ── */
|
||||||
|
.vis-code {
|
||||||
|
padding: 28px 32px; font-size: 13px; line-height: 2; white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.vis-code .kw { color: var(--ac); }
|
||||||
|
.vis-code .str { color: var(--gn); }
|
||||||
|
.vis-code .tag { color: var(--yl); }
|
||||||
|
.vis-code .link { color: var(--ac2); }
|
||||||
|
.vis-code .cm { opacity: 0.3; }
|
||||||
|
.vis-code .hi { color: var(--hi); }
|
||||||
|
|
||||||
|
.vis-tags { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; align-content: flex-start; }
|
||||||
|
.vtag {
|
||||||
|
font-family: var(--pixel); font-size: 11px; padding: 8px 18px;
|
||||||
|
border-radius: 6px; border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-keys { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.vkey {
|
||||||
|
font-family: var(--pixel); font-size: 12px; padding: 10px 16px;
|
||||||
|
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
|
||||||
|
color: var(--hi); min-width: 40px; text-align: center;
|
||||||
|
box-shadow: 0 3px 0 var(--bdr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-cards { padding: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.vcard {
|
||||||
|
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
|
||||||
|
padding: 16px; border-top: 3px solid;
|
||||||
|
}
|
||||||
|
.vcard-t { font-family: var(--pixel); font-size: 10px; color: var(--hi); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.vis-gitea { padding: 28px 32px; }
|
||||||
|
.vis-gitea .ghdr {
|
||||||
|
font-family: var(--pixel); font-size: 10px; color: var(--tx);
|
||||||
|
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.vis-gitea .issue-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
|
||||||
|
}
|
||||||
|
.vis-gitea .issue-row:last-child { border-bottom: none; }
|
||||||
|
.vis-gitea .issue-state { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.vis-gitea .issue-title { color: var(--hi); flex: 1; }
|
||||||
|
.vis-gitea .issue-labels { display: flex; gap: 4px; }
|
||||||
|
.vis-gitea .issue-label {
|
||||||
|
font-family: var(--pixel); font-size: 8px; padding: 2px 8px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.vis-gitea .issue-num { font-family: var(--pixel); font-size: 10px; color: var(--tx); }
|
||||||
|
|
||||||
|
.vis-relay { padding: 28px 32px; font-size: 12px; line-height: 2.2; }
|
||||||
|
.vis-relay .node {
|
||||||
|
display: inline-block; padding: 4px 14px; border-radius: 4px;
|
||||||
|
font-family: var(--pixel); font-size: 10px; border: 1px solid; margin: 2px;
|
||||||
|
}
|
||||||
|
.vis-relay .arrow { color: var(--bdr); margin: 0 6px; }
|
||||||
|
|
||||||
|
/* ── Screenshot ── */
|
||||||
|
.hero-screenshot {
|
||||||
|
margin-top: 56px; border-radius: 10px; overflow: hidden;
|
||||||
|
border: 1px solid var(--bdr);
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.hero-screenshot img { display: block; width: 100%; height: auto; }
|
||||||
|
|
||||||
|
/* ── Email visual ── */
|
||||||
|
.vis-inbox { padding: 24px 32px; }
|
||||||
|
.vis-inbox .inbox-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
|
||||||
|
}
|
||||||
|
.vis-inbox .inbox-row:last-child { border-bottom: none; }
|
||||||
|
.vis-inbox .inbox-from { width: 120px; color: var(--hi); flex-shrink: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.vis-inbox .inbox-subject { flex: 1; color: var(--tx); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.vis-inbox .inbox-badge {
|
||||||
|
font-family: var(--pixel); font-size: 8px; padding: 2px 8px;
|
||||||
|
border-radius: 3px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vis-inbox .inbox-row.spam { opacity: 0.4; }
|
||||||
|
.vis-inbox .inbox-row.spam .inbox-from { text-decoration: line-through; }
|
||||||
|
|
||||||
|
/* ── CTA ── */
|
||||||
|
.cta-section {
|
||||||
|
min-height: 60vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 80px 48px;
|
||||||
|
}
|
||||||
|
.cta-section h2 {
|
||||||
|
font-family: var(--pixel); font-size: clamp(24px, 4vw, 48px);
|
||||||
|
color: var(--hi); margin-bottom: 16px; letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
.cta-section p { font-size: 14px; margin-bottom: 40px; max-width: 440px; margin-left: auto; margin-right: auto; }
|
||||||
|
.cta-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.cta-btn {
|
||||||
|
font-family: var(--pixel); font-size: 11px; padding: 12px 28px;
|
||||||
|
border-radius: 6px; transition: all 0.2s; text-decoration: none;
|
||||||
|
}
|
||||||
|
.cta-btn.primary { background: var(--ac); color: var(--bg); }
|
||||||
|
.cta-btn.primary:hover { background: #7dd3fc; }
|
||||||
|
.cta-btn.outline { border: 1px solid var(--bdr); color: var(--tx); }
|
||||||
|
.cta-btn.outline:hover { border-color: var(--ac); color: var(--ac); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 24px 48px; border-top: 1px solid var(--bdr);
|
||||||
|
font-family: var(--pixel); font-size: 9px; color: var(--tx); opacity: 0.3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.section { padding: 60px 24px; }
|
||||||
|
.feat { grid-template-columns: 1fr; gap: 40px; }
|
||||||
|
.feat.reverse .feat-text, .feat.reverse .feat-visual { order: unset; }
|
||||||
|
.section-label { font-size: clamp(36px, 10vw, 80px); }
|
||||||
|
header { padding: 0 20px; }
|
||||||
|
header nav a:not(.gh-btn) { display: none; }
|
||||||
|
footer { padding: 24px; }
|
||||||
|
.cta-section { padding: 60px 24px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="inner">
|
||||||
|
<span class="brand">hyprcrd</span>
|
||||||
|
<nav>
|
||||||
|
<a href="#search">search</a>
|
||||||
|
<a href="#tags">tags</a>
|
||||||
|
<a href="#editor">editor</a>
|
||||||
|
<a href="#gitea">gitea</a>
|
||||||
|
<a href="#relay">relay</a>
|
||||||
|
<a href="#email">email</a>
|
||||||
|
<a href="#deploy">deploy</a>
|
||||||
|
<a href="https://git.uesome.de/tanztee/hyprcrd" class="gh-btn">source</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section class="section hero">
|
||||||
|
<div class="hero-brand">
|
||||||
|
<i class="ph ph-terminal-window"></i>
|
||||||
|
<span><span class="typed" id="hero-typed"></span><span class="cursor" id="hero-cursor"></span></span>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">
|
||||||
|
stop losing<br>
|
||||||
|
<span class="accent">information</span><span class="dim">.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-sub">
|
||||||
|
Cards. Tags. Wiki-links. Markdown. Calendar. Encrypted sync between devices.
|
||||||
|
One SQLite file. Zero cloud. Built for finding things, not filing them.
|
||||||
|
</p>
|
||||||
|
<div class="hero-terminal">
|
||||||
|
<span class="p">$</span> <span class="c">git clone https://git.uesome.de/tanztee/hyprcrd</span><br>
|
||||||
|
<span class="p">$</span> <span class="c">cd hyprcrd && docker compose up -d</span><br>
|
||||||
|
<span class="d"># open localhost:8088 — set password — done</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-screenshot">
|
||||||
|
<img src="screenshot.png" alt="hyprcrd board view with cards, tags, and buckets">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="search">
|
||||||
|
<div class="section-label">SEARCH</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Type to find. Instantly.</h3>
|
||||||
|
<p>Full-text search hits titles, card bodies, and tags at the same time. Combine <code>#tag</code> filters with free text — <code>#project deploy script</code> finds exactly what you think it does.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Search + replace across every card in the database</li>
|
||||||
|
<li><code>#tag1 #tag2</code> for AND filtering</li>
|
||||||
|
<li>Scope to a specific bucket</li>
|
||||||
|
<li>Drag search results onto each other to group cards</li>
|
||||||
|
<li>Preview panel shows rendered markdown</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-code"><span class="cm">></span> <span class="hi">#project deploy</span>
|
||||||
|
|
||||||
|
<span class="cm"> 3 results in 2ms</span>
|
||||||
|
|
||||||
|
<span class="kw">API redesign</span>
|
||||||
|
<span class="cm">...the <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> script handles
|
||||||
|
staging and prod environments...</span>
|
||||||
|
|
||||||
|
<span class="kw">Infra checklist</span>
|
||||||
|
<span class="cm">...<mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> to k8s cluster
|
||||||
|
after running integration tests...</span>
|
||||||
|
|
||||||
|
<span class="kw">Sprint retro</span>
|
||||||
|
<span class="cm">...discussed <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark>
|
||||||
|
frequency and rollback strategy...</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="tags">
|
||||||
|
<div class="section-label">TAGS</div>
|
||||||
|
<div class="feat reverse">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Write a hashtag.<br>It becomes structure.</h3>
|
||||||
|
<p>No separate tag dialog. No filing step. Write <code>#project</code> in the card body and it extracts automatically into a clickable, filterable tag.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Hierarchical: <code>#project/frontend/auth</code> — each segment is independently clickable</li>
|
||||||
|
<li>Click to filter, Ctrl+click to combine (AND logic)</li>
|
||||||
|
<li>Tag quick-pick with <kbd>T</kbd> — searchable overlay of all existing tags</li>
|
||||||
|
<li>Assign via right-click context menu</li>
|
||||||
|
<li>Super-buckets: nest buckets into collapsible groups</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-tags">
|
||||||
|
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#project</span>
|
||||||
|
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/api</span>
|
||||||
|
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend</span>
|
||||||
|
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend/auth</span>
|
||||||
|
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#bug</span>
|
||||||
|
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#urgent</span>
|
||||||
|
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#ops</span>
|
||||||
|
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#done</span>
|
||||||
|
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#meeting</span>
|
||||||
|
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#termin</span>
|
||||||
|
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#research</span>
|
||||||
|
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#idea</span>
|
||||||
|
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#geburtstag</span>
|
||||||
|
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#personal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="editor">
|
||||||
|
<div class="section-label">EDITOR</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Markdown that does more.</h3>
|
||||||
|
<p>Every card is a markdown document with live preview. Enriched with tools that make it a knowledge system, not just a text file.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Wiki-links: <code>[[Card Title]]</code> — auto-complete, backlinks, split view</li>
|
||||||
|
<li>Snippets: <code>?dat</code> <code>?table</code> <code>?code</code> <code>?todo</code></li>
|
||||||
|
<li>Inline calculator: <code>?3+5</code> space → 8</li>
|
||||||
|
<li>Line operations: Alt+Up/Down to move, Shift+Alt to duplicate</li>
|
||||||
|
<li>Upload images, audio, PDFs — paste or drag</li>
|
||||||
|
<li>Asset picker: thumbnail grid with search</li>
|
||||||
|
<li>Extract selection to new card with auto-link</li>
|
||||||
|
<li>Find & replace within the card (Ctrl+H)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-code"><span class="hi">## Sprint planning Q3</span>
|
||||||
|
|
||||||
|
<span class="cm">Goals for next quarter:</span>
|
||||||
|
|
||||||
|
- [x] Finish API redesign
|
||||||
|
- [x] Migrate auth to <span class="link">[[Architecture RFC]]</span>
|
||||||
|
- [ ] Load testing
|
||||||
|
- [ ] Deploy to prod
|
||||||
|
|
||||||
|
<span class="kw">**Deadline:**</span> 2026-07-01
|
||||||
|
<span class="kw">**Owner:**</span> @alice
|
||||||
|
|
||||||
|
See also: <span class="link">[[Infra checklist]]</span>
|
||||||
|
|
||||||
|
<span class="tag">#project/api</span> <span class="tag">#meeting</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="gitea">
|
||||||
|
<div class="section-label"><span class="accent">GITEA</span></div>
|
||||||
|
<div class="feat reverse">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Cards become tickets.<br>Tickets stay linked.</h3>
|
||||||
|
<p>Connect any number of Gitea instances. Browse issues, create tickets directly from cards, and keep your project management in sync without leaving hyprcrd.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Multi-account: connect unlimited Gitea servers with token auth</li>
|
||||||
|
<li>Default repo per account — pre-fills create form, stays editable</li>
|
||||||
|
<li>Right-click any card → <em>Make ticket</em>: title + body pre-fill the issue form</li>
|
||||||
|
<li>Full issue browser: filter by state, scope (assigned/created/mentioned)</li>
|
||||||
|
<li>Label picker, milestone selector, assignee multi-select</li>
|
||||||
|
<li>Repo autocomplete with fuzzy search across all repos</li>
|
||||||
|
<li>Inline issue detail: view body, labels, assignees, edit title, toggle state</li>
|
||||||
|
<li>Pagination for large repos</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-gitea">
|
||||||
|
<div class="ghdr">
|
||||||
|
<span style="color:var(--ac);">git.example.com</span>
|
||||||
|
<span style="opacity:0.3">/</span>
|
||||||
|
<span style="color:var(--hi);">team/hyprcrd</span>
|
||||||
|
<span style="margin-left:auto;padding:2px 10px;border-radius:3px;background:rgba(52,211,153,0.08);color:var(--gn);border:1px solid rgba(52,211,153,0.15);font-family:var(--pixel);">open</span>
|
||||||
|
</div>
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-state" style="background:var(--gn);"></span>
|
||||||
|
<span class="issue-title">API redesign: migrate to v3 endpoints</span>
|
||||||
|
<span class="issue-labels">
|
||||||
|
<span class="issue-label" style="background:rgba(56,189,248,0.1);color:var(--ac);">enhancement</span>
|
||||||
|
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">api</span>
|
||||||
|
</span>
|
||||||
|
<span class="issue-num">#142</span>
|
||||||
|
</div>
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-state" style="background:var(--gn);"></span>
|
||||||
|
<span class="issue-title">Auth timeout on mobile devices</span>
|
||||||
|
<span class="issue-labels">
|
||||||
|
<span class="issue-label" style="background:rgba(251,113,133,0.1);color:var(--rd);">bug</span>
|
||||||
|
<span class="issue-label" style="background:rgba(251,191,36,0.1);color:var(--yl);">priority</span>
|
||||||
|
</span>
|
||||||
|
<span class="issue-num">#139</span>
|
||||||
|
</div>
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-state" style="background:var(--gn);"></span>
|
||||||
|
<span class="issue-title">Add CalDAV recurring event support</span>
|
||||||
|
<span class="issue-labels">
|
||||||
|
<span class="issue-label" style="background:rgba(52,211,153,0.1);color:var(--gn);">feature</span>
|
||||||
|
</span>
|
||||||
|
<span class="issue-num">#137</span>
|
||||||
|
</div>
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-state" style="background:var(--ac2);"></span>
|
||||||
|
<span class="issue-title">Relay: per-pair key exchange for strict E2E</span>
|
||||||
|
<span class="issue-labels">
|
||||||
|
<span class="issue-label" style="background:rgba(244,114,182,0.1);color:var(--pk);">security</span>
|
||||||
|
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">relay</span>
|
||||||
|
</span>
|
||||||
|
<span class="issue-num">#131</span>
|
||||||
|
</div>
|
||||||
|
<div class="issue-row">
|
||||||
|
<span class="issue-state" style="background:var(--tx);opacity:0.3;"></span>
|
||||||
|
<span class="issue-title" style="opacity:0.4;">Export: include plugin settings in backup</span>
|
||||||
|
<span class="issue-labels">
|
||||||
|
<span class="issue-label" style="background:rgba(56,189,248,0.05);color:var(--tx);opacity:0.5;">enhancement</span>
|
||||||
|
</span>
|
||||||
|
<span class="issue-num" style="opacity:0.4;">#128</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="relay">
|
||||||
|
<div class="section-label">RELAY</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Sync between devices.<br>Share with people.</h3>
|
||||||
|
<p>A self-hosted message relay that connects multiple hyprcrd instances. Per-user encryption. Collaborative buckets with exclusive card locking. Encrypted chat with read receipts.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Multi-tenant: each user gets their own login + encryption secret</li>
|
||||||
|
<li>Cards sync between devices — push from one, poll from another</li>
|
||||||
|
<li>Collaborative buckets: shared editing with exclusive locks</li>
|
||||||
|
<li>Chat: encrypted messages with delivery + read receipts</li>
|
||||||
|
<li>Compose + inbox UI works on mobile</li>
|
||||||
|
<li>24h ephemeral — the relay routes, it doesn't archive</li>
|
||||||
|
<li>AES-256-CBC at every hop, bcrypt passwords, master key at rest</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-relay">
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<span class="node" style="border-color:var(--ac);color:var(--ac);">alice</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">encrypts with own secret</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:20px;padding-left:24px;border-left:2px solid var(--bdr);">
|
||||||
|
<span style="font-size:11px;color:var(--tx);">
|
||||||
|
1. authenticate sender<br>
|
||||||
|
2. decrypt with alice's secret<br>
|
||||||
|
3. re-encrypt with inter-tenant key<br>
|
||||||
|
4. store (24h TTL)<br>
|
||||||
|
5. on bob's poll: decrypt<br>
|
||||||
|
6. re-encrypt with bob's secret<br>
|
||||||
|
7. deliver
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">re-encrypted for recipient</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="node" style="border-color:var(--gn);color:var(--gn);">bob</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="email">
|
||||||
|
<div class="section-label">EMAIL</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Built-in mail client.<br>Spam dies on arrival.</h3>
|
||||||
|
<p>A focused IMAP mail client built into hyprcrd. Not a replacement for your full email workflow — a triage tool. See what's real, nuke what's not, move on.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Multi-account IMAP with encrypted credential storage</li>
|
||||||
|
<li>Automatic spam scoring — sender reputation, header analysis, pattern matching</li>
|
||||||
|
<li>One-click spam/ham training per sender — builds your personal allowlist/blocklist</li>
|
||||||
|
<li>Bulk select all flagged spam and delete in one action</li>
|
||||||
|
<li>Suspicious messages marked with warning badge and score breakdown</li>
|
||||||
|
<li>HTML rendering with external images blocked by default (tracking pixel protection)</li>
|
||||||
|
<li>Plain text fallback — always available, always safe</li>
|
||||||
|
<li>Pagination for large mailboxes, keyboard-friendly navigation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-inbox">
|
||||||
|
<div class="inbox-row">
|
||||||
|
<span class="inbox-from">@alice</span>
|
||||||
|
<span class="inbox-subject">Re: Sprint planning — updated timeline attached</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row">
|
||||||
|
<span class="inbox-from">GitHub</span>
|
||||||
|
<span class="inbox-subject">[hyprcrd] PR #142 merged: API v3 migration</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(56,189,248,0.1);color:var(--ac);">notification</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row">
|
||||||
|
<span class="inbox-from">@carol</span>
|
||||||
|
<span class="inbox-subject">Relay docs draft — please review before Friday</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row spam">
|
||||||
|
<span class="inbox-from">prince.ng@mail.ru</span>
|
||||||
|
<span class="inbox-subject">URGENT: $4.5M inheritance awaiting your claim</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 94%</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row spam">
|
||||||
|
<span class="inbox-from">deals@sz-wholesale.cn</span>
|
||||||
|
<span class="inbox-subject">Limited offer!!! Best prices on electronics!!!</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 87%</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row">
|
||||||
|
<span class="inbox-from">Sentry</span>
|
||||||
|
<span class="inbox-subject">New issue: TypeError in /api/relay/push</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(251,191,36,0.1);color:var(--yl);">? suspicious</span>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-row spam">
|
||||||
|
<span class="inbox-from">crypto-gains@proton.me</span>
|
||||||
|
<span class="inbox-subject">Make 500% returns with this one trick</span>
|
||||||
|
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 98%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="keys">
|
||||||
|
<div class="section-label">KEYBOARD</div>
|
||||||
|
<div class="feat reverse">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>40+ shortcuts.<br>Zero mouse required.</h3>
|
||||||
|
<p>Every action has a key binding. Arrow keys navigate the board. Number keys switch buckets. Quick capture creates cards without leaving the keyboard.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Press <kbd>?</kbd> for the full shortcut reference</li>
|
||||||
|
<li>Shortcuts auto-suppress in input fields</li>
|
||||||
|
<li>Context-aware: editor shortcuts only fire in the editor</li>
|
||||||
|
<li>Styled tooltips and inline help across the UI</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-keys">
|
||||||
|
<span class="vkey">?</span>
|
||||||
|
<span class="vkey">N</span>
|
||||||
|
<span class="vkey">J</span>
|
||||||
|
<span class="vkey">T</span>
|
||||||
|
<span class="vkey">C</span>
|
||||||
|
<span class="vkey">W</span>
|
||||||
|
<span class="vkey">M</span>
|
||||||
|
<span class="vkey">/</span>
|
||||||
|
<span class="vkey">Esc</span>
|
||||||
|
<span class="vkey">F1</span>
|
||||||
|
<span class="vkey">Ctrl K</span>
|
||||||
|
<span class="vkey">Ctrl N</span>
|
||||||
|
<span class="vkey">Ctrl H</span>
|
||||||
|
<span class="vkey">Ctrl S</span>
|
||||||
|
<span class="vkey">Ctrl Z</span>
|
||||||
|
<span class="vkey">↑</span>
|
||||||
|
<span class="vkey">↓</span>
|
||||||
|
<span class="vkey">←</span>
|
||||||
|
<span class="vkey">→</span>
|
||||||
|
<span class="vkey">Enter</span>
|
||||||
|
<span class="vkey">Tab</span>
|
||||||
|
<span class="vkey">1</span>
|
||||||
|
<span class="vkey">2</span>
|
||||||
|
<span class="vkey">3</span>
|
||||||
|
<span class="vkey">...</span>
|
||||||
|
<span class="vkey">9</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="deploy">
|
||||||
|
<div class="section-label">DEPLOY</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Docker. PHP host. Desktop app.</h3>
|
||||||
|
<p>Three deployment paths, same app. The Electron build bundles a static PHP binary — no PHP installation needed. Data lives outside the app bundle and survives updates.</p>
|
||||||
|
<ul>
|
||||||
|
<li>One SQLite file — back up by copying <code>data/hyprcrd.db</code></li>
|
||||||
|
<li>Export as JSON+media ZIP or Obsidian/Logseq markdown vault</li>
|
||||||
|
<li>Plugin system: drop a folder, reload, feature appears</li>
|
||||||
|
<li>Bundled: chat, email, Gitea, canvas, slideshows, Logseq import, watch</li>
|
||||||
|
<li>Fully themed: design tokens, presets, build your own</li>
|
||||||
|
<li>MIT licensed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div class="vis-cards">
|
||||||
|
<div class="vcard" style="border-color:var(--ac);">
|
||||||
|
<div class="vcard-t">Docker</div>
|
||||||
|
<div style="font-size:11px;color:var(--gn);line-height:1.8;">docker compose up -d<br><span style="opacity:0.3">→ localhost:8088</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="vcard" style="border-color:var(--ac2);">
|
||||||
|
<div class="vcard-t">PHP host</div>
|
||||||
|
<div style="font-size:11px;color:var(--tx);line-height:1.8;">Upload to webspace<br>Set docroot → public/</div>
|
||||||
|
</div>
|
||||||
|
<div class="vcard" style="border-color:var(--gn);">
|
||||||
|
<div class="vcard-t">Electron</div>
|
||||||
|
<div style="font-size:11px;color:var(--gn);line-height:1.8;">npm run dist<br><span style="opacity:0.3">→ AppImage / DMG / NSIS</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="vcard" style="border-color:var(--yl);">
|
||||||
|
<div class="vcard-t">Data</div>
|
||||||
|
<div style="font-size:11px;color:var(--tx);line-height:1.8;">data/hyprcrd.db<br>public/uploads/</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta-section">
|
||||||
|
<div>
|
||||||
|
<h2>your brain, self-hosted.</h2>
|
||||||
|
<p>Set a password. Start writing. Find everything.</p>
|
||||||
|
<div class="cta-links">
|
||||||
|
<a href="https://git.uesome.de/tanztee/hyprcrd" class="cta-btn primary">get the source</a>
|
||||||
|
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/README.md" class="cta-btn outline">readme</a>
|
||||||
|
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/relay/README.md" class="cta-btn outline">relay docs</a>
|
||||||
|
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/electron/README.md" class="cta-btn outline">electron</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>hyprcrd · MIT license · v1.3.3</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const text = 'hyprcrd';
|
||||||
|
const el = document.getElementById('hero-typed');
|
||||||
|
const cursor = document.getElementById('hero-cursor');
|
||||||
|
if (!el) return;
|
||||||
|
let i = 0;
|
||||||
|
function type() {
|
||||||
|
if (i < text.length) {
|
||||||
|
el.textContent += text[i++];
|
||||||
|
setTimeout(type, 90 + Math.random() * 80);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => cursor.classList.add('cursor-done'), 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(type, 400);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Ad hoc server for the hyprcrd website
|
||||||
|
PORT=${1:-3000}
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
echo " hyprcrd website: http://localhost:$PORT"
|
||||||
|
python3 -m http.server "$PORT"
|
||||||
Reference in new issue
Block a user