Compare commits

..
Author SHA1 Message Date
thandClaude Opus 4.6 b9345a410f watch plugin: analog clock overlay with SVG watchface
Simple watchface with second-hand wobble animation, 5-min idle
auto-show, W key toggle, light/dark theme colors, backdrop blur
from theme settings. Grace period prevents instant dismiss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:16:25 +02:00
thandClaude Opus 4.6 00d14b3015 relay: suppress poll re-renders during user actions
Inbox poll continues fetching but skips DOM updates when user is typing,
deleting, or confirming. Quiet mode diffs message IDs to avoid
unnecessary re-renders. Focus/blur tracking on all inputs auto-guards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:26:26 +02:00
thandClaude Opus 4.6 f465306c1d global tooltip system, migrate all title attrs to data-tip/data-help
New tooltip.js module with styled tooltips (data-tip) and multi-line
inline help (data-help). Migrates all ~70 native title attributes
across index.php, JS modules, and all plugins. Adds inline help on
9 strategic UI elements (split, superview, zen, extract, assets, etc).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:55:17 +02:00
thandClaude Opus 4.6 527305355c super-buckets: split tab click (activate) from caret click (dropdown)
Clicking the super-bucket tab now activates the last-shown child
directly. Only the caret arrow on the right opens the dropdown popover.
Caret has its own hit area with accent hover highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:37:38 +02:00
thandClaude Opus 4.6 04b52ecc19 gitea: default repo setting; editor: asset picker with thumbnail grid
Gitea plugin: default_repo field per account (stored in DB, pre-fills
create issue form but stays editable). Editor toolbar: asset picker
flyout with 4-column thumbnail grid and live search filter. Clicking
a tile inserts the matching markdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:26:04 +02:00
thandClaude Opus 4.6 c5bf8b0407 docs: update all READMEs for v1.1.0
Add Electron desktop app section, document all 8 plugins, update
features list (assets, super-buckets, find/replace, arrow nav, PDF
upload), fix relay docs (chat shipped, not future), add missing API
endpoints, correct electron data storage paths and php-bin fetch info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:00:06 +02:00
thandClaude Opus 4.6 d5f44370b6 fix: PHP upload limits, import error dialog, earlier footer breakpoint
- Set upload_max_filesize=64M and display_errors=0 for Electron PHP
- Import shows error dialog instead of silently failing
- Footer count repositions at 1450px instead of 1250px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:46:56 +02:00
40 changed files with 994 additions and 178 deletions

No files matched your search

+53 -8
View File
@@ -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:
+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.1.0'); define('APP_VERSION', '1.3.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
+7 -2
View File
@@ -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
+3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.1.0", "version": "1.3.3",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+9 -9
View File
@@ -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.
+47 -1
View File
@@ -1,4 +1,32 @@
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; } body.no-scanlines::after { display: none !important; }
@@ -28,7 +56,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; }
@@ -821,6 +849,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);
+36 -35
View File
@@ -1227,7 +1227,7 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span> <i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd> <kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</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"> <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>
</div> </div>
@@ -1256,19 +1256,19 @@ if (str_starts_with($route, '/api/')) {
</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">
@@ -1650,7 +1650,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">
@@ -1693,7 +1693,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>
@@ -1935,7 +1935,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 +2018,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">
@@ -2170,10 +2170,10 @@ if (str_starts_with($route, '/api/')) {
</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..." 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,32 +2182,33 @@ 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>
</div> </div>
<div class="flex flex-1 min-h-0 overflow-hidden"> <div class="flex flex-1 min-h-0 overflow-hidden">
@@ -2219,9 +2220,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>
+8 -2
View File
@@ -13,6 +13,7 @@ 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';
@@ -143,10 +144,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 = '';
}); });
@@ -266,6 +271,7 @@ 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: '/', 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]')];
+22 -12
View File
@@ -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,11 +462,21 @@ 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]');
e.stopPropagation(); if (caret) {
openSuperDropdown(t, parseInt(bid, 10)); caret.addEventListener('click', (e) => {
e.stopPropagation();
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(); });
+1 -1
View File
@@ -76,7 +76,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('');
+4 -4
View File
@@ -433,7 +433,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 +554,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 +615,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 +898,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>`;
+106 -1
View File
@@ -102,7 +102,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;
@@ -363,6 +363,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;
@@ -1184,6 +1185,110 @@ $('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 => {
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 => {
+91
View File
@@ -0,0 +1,91 @@
// ── 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 target = e.target.closest('[data-tip],[data-help]');
if (!target) return;
clearTimeout(hideTimer);
showTimer = setTimeout(() => show(target), SHOW_DELAY);
}
function onLeave(e) {
const target = e.target.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);
+9 -9
View File
@@ -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>
+3 -3
View File
@@ -4,8 +4,8 @@
<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 flex-1">chat</span>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" title="new conversation"><i class="ph ph-plus-square text-sm"></i></button> <button id="chat-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" title="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></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>
@@ -29,7 +29,7 @@
<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>
+12 -12
View File
@@ -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>
+6 -6
View File
@@ -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">
+11 -1
View File
@@ -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);
+15 -15
View File
@@ -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>
+16 -6
View File
@@ -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() {
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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">
+63
View File
@@ -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);
});
});
+1
View File
@@ -180,6 +180,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 => {
+28 -8
View File
@@ -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);
}); });
+1
View File
@@ -50,6 +50,7 @@ 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',
'./theme.test.js', './theme.test.js',
]; ];
@@ -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

+10
View File
@@ -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"]
}
}
+93
View File
@@ -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; }
}
+13
View File
@@ -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>
+169
View File
@@ -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
View File
@@ -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)
+45 -20
View File
@@ -113,16 +113,26 @@ 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 });
}, 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. */
@@ -371,18 +381,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');
list.innerHTML = ` if (!quiet) {
<div class="skeleton-row"><div class="skel tall"></div></div> list.innerHTML = `
<div class="skeleton-row"><div class="skel short"></div></div> <div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel"></div></div> <div class="skeleton-row"><div class="skel short"></div></div>
`; <div class="skeleton-row"><div class="skel"></div></div>
`;
}
const res = await api('api/messages/inbox'); const 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);
@@ -408,6 +427,7 @@ async function loadInbox() {
} }
async function deleteMessage(id, btn) { async function deleteMessage(id, btn) {
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,6 +445,7 @@ 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(); loadInbox();
@@ -435,6 +456,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,18 +465,21 @@ async function deleteMessage(id, btn) {
window.deleteMessage = deleteMessage; window.deleteMessage = deleteMessage;
async function deleteAllMessages() { async function deleteAllMessages() {
const res = await api('api/messages/inbox'); inboxBusy = true;
const messages = res?.messages || []; try {
if (!messages.length) return; const res = await api('api/messages/inbox');
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return; const messages = res?.messages || [];
let ok = 0; if (!messages.length) return;
for (const m of messages) { if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
const r = await api('api/messages/' + m.id, { method: 'DELETE' }); let ok = 0;
if (r?.ok) ok++; for (const m of messages) {
} const r = await api('api/messages/' + m.id, { method: 'DELETE' });
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok'); if (r?.ok) ok++;
loadInbox(); }
refreshMe(); toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
} finally { inboxBusy = false; }
} }
window.deleteAllMessages = deleteAllMessages; window.deleteAllMessages = deleteAllMessages;