Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9345a410f | ||
|
|
00d14b3015 | ||
|
|
f465306c1d | ||
|
|
527305355c | ||
|
|
04b52ecc19 | ||
|
|
c5bf8b0407 | ||
|
|
d5f44370b6 |
No files matched your search
@@ -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
@@ -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
@@ -10,8 +10,13 @@ per platform so users don't need PHP installed.
|
|||||||
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
||||||
4. On quit, the PHP process is killed cleanly
|
4. On quit, the PHP process is killed cleanly
|
||||||
|
|
||||||
Data is stored in `data/hyprcrd.db` (SQLite), relative to the project root
|
User data (database + uploads) is stored **outside** the app bundle so it
|
||||||
(dev) or inside the app's `resources/` directory (packaged).
|
survives updates:
|
||||||
|
|
||||||
|
- **Dev**: `electron/appdata/`
|
||||||
|
- **Linux**: `~/.config/hyprcrd/data/`
|
||||||
|
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
|
||||||
|
- **Windows**: `%APPDATA%/hyprcrd/data/`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ async function startPhpServer() {
|
|||||||
console.log('[hyprcrd] port:', serverPort);
|
console.log('[hyprcrd] port:', serverPort);
|
||||||
|
|
||||||
phpProcess = spawn(phpBin, [
|
phpProcess = spawn(phpBin, [
|
||||||
|
'-d', 'upload_max_filesize=64M',
|
||||||
|
'-d', 'post_max_size=66M',
|
||||||
|
'-d', 'display_errors=0',
|
||||||
'-S', `127.0.0.1:${serverPort}`,
|
'-S', `127.0.0.1:${serverPort}`,
|
||||||
'-t', docRoot,
|
'-t', docRoot,
|
||||||
routerFile,
|
routerFile,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.1.0",
|
"version": "1.3.3",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ installation.
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Get static builds from https://github.com/crazywhalecc/static-php-cli/releases
|
Get static builds from https://dl.static-php.dev/static-php-cli/common/
|
||||||
(look for `php-8.3-*-cli` assets). Required extensions: pdo_sqlite, zip, session.
|
(look for `php-8.3.*-cli-*` files). Required extensions: pdo_sqlite, zip, session.
|
||||||
|
|
||||||
linux/php — x86_64 Linux ELF binary
|
linux/php — x86_64 Linux ELF binary
|
||||||
darwin/php — macOS universal binary (arm64 + x86_64)
|
darwin/php — macOS aarch64 binary
|
||||||
win32/php.exe — Windows PE binary
|
win32/php.exe — Windows PE binary (not available from static-php-cli; use windows.php.net)
|
||||||
|
|
||||||
## Quick fetch (Linux example)
|
## Quick fetch
|
||||||
|
|
||||||
curl -L -o linux/php \
|
Use the included script from the `electron/` directory:
|
||||||
"https://dl.static-php.dev/static-php-cli/common/php-8.3.23-cli-linux-x86_64.tar.gz" \
|
|
||||||
&& tar -xzf linux/php -C linux/ && rm linux/php.tar.gz
|
|
||||||
|
|
||||||
Make sure the binary is executable: `chmod +x linux/php`
|
./fetch-php.sh
|
||||||
|
|
||||||
|
This auto-detects the latest PHP 8.3 version and downloads Linux + macOS binaries.
|
||||||
+47
-1
@@ -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
@@ -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
@@ -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]')];
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ function renderBar() {
|
|||||||
// render (e.g. bucket reorder, plain renderBar() call).
|
// render (e.g. bucket reorder, plain renderBar() call).
|
||||||
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" data-tip="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
||||||
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" data-tip="current"><i class="ph ph-lightning text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" data-tip="favorites"><i class="ph ph-star text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" data-tip="journal"><i class="ph ph-notebook text-sm"></i></button>
|
||||||
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" data-tip="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
||||||
${topLevel.map(b => {
|
${topLevel.map(b => {
|
||||||
const kids = childrenOf(b.id);
|
const kids = childrenOf(b.id);
|
||||||
const isSuper = kids.length > 0;
|
const isSuper = kids.length > 0;
|
||||||
@@ -309,7 +309,7 @@ function renderBar() {
|
|||||||
const shown = tileFor(b);
|
const shown = tileFor(b);
|
||||||
const c = shown.color || '#64748b';
|
const c = shown.color || '#64748b';
|
||||||
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
|
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
|
||||||
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`;
|
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><span class="super-caret" data-super-caret="${b.id}"><i class="ph ph-caret-down text-[10px]"></i></span></button>`;
|
||||||
}
|
}
|
||||||
const c = b.color || '#64748b';
|
const c = b.color || '#64748b';
|
||||||
const active = state.activeBucket == b.id;
|
const active = state.activeBucket == b.id;
|
||||||
@@ -400,7 +400,7 @@ function renderBar() {
|
|||||||
const c = m.color || '#64748b';
|
const c = m.color || '#64748b';
|
||||||
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
||||||
const isParent = m.id === parentId;
|
const isParent = m.id === parentId;
|
||||||
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" title="parent"></i>' : ''}</button>`;
|
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" data-tip="parent"></i>' : ''}</button>`;
|
||||||
};
|
};
|
||||||
pop.innerHTML = members.map(renderEntry).join('');
|
pop.innerHTML = members.map(renderEntry).join('');
|
||||||
// Append inside #bucket-bar (not document.body) so the collab
|
// Append inside #bucket-bar (not document.body) so the collab
|
||||||
@@ -462,12 +462,22 @@ function renderBar() {
|
|||||||
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
||||||
|
|
||||||
if (isSuperTile) {
|
if (isSuperTile) {
|
||||||
// Click opens the dropdown popover instead of activating the
|
// Caret arrow opens the dropdown; clicking the rest of the
|
||||||
// parent directly. The dropdown lists parent + every child.
|
// tab activates the last-shown child (or parent).
|
||||||
t.addEventListener('click', (e) => {
|
const caret = t.querySelector('[data-super-caret]');
|
||||||
|
if (caret) {
|
||||||
|
caret.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openSuperDropdown(t, parseInt(bid, 10));
|
openSuperDropdown(t, parseInt(bid, 10));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
t.addEventListener('click', () => {
|
||||||
|
const shownId = t.dataset.superShown;
|
||||||
|
setActiveBucket(shownId || bid);
|
||||||
|
state.activeGroup = null;
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ export async function openGallery() {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
|
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
|
||||||
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" data-tip="new card"><i class="ph ph-plus text-sm"></i></button>
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" data-tip="new slide"><i class="ph ph-image-square text-sm"></i></button>
|
||||||
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function renderMd(src) {
|
|||||||
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
|
||||||
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
|
||||||
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
|
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
|
||||||
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
|
return `<span class="card-link broken" data-tip="card not found">${esc(title)}</span>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = marked.parse(linked);
|
let html = marked.parse(linked);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function render() {
|
|||||||
<span>${esc(i.label)}</span>
|
<span>${esc(i.label)}</span>
|
||||||
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
|
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" title="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
|
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" data-tip="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
banner.querySelectorAll('[data-notif-id]').forEach(el => {
|
banner.querySelectorAll('[data-notif-id]').forEach(el => {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
<i class="ph ph-envelope text-t-accent text-base"></i>
|
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
||||||
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||||
<button id="email-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
<button id="email-refresh" class="toolbar-btn" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="manage accounts"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||||
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
<div class="sticky top-0 z-20 bg-t-bg">
|
<div class="sticky top-0 z-20 bg-t-bg">
|
||||||
<!-- List toolbar: pagination + spam quick-select -->
|
<!-- List toolbar: pagination + spam quick-select -->
|
||||||
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
||||||
<button id="email-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="email-page-prev" class="toolbar-btn" data-tip="previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
<button id="email-page-next" class="toolbar-btn" data-tip="next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Select all messages flagged as spam">
|
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="select spam">
|
||||||
<i class="ph ph-warning-octagon text-sm"></i>
|
<i class="ph ph-warning-octagon text-sm"></i>
|
||||||
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -54,16 +54,16 @@
|
|||||||
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
||||||
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Mark sender as spam (future mail from same sender will be flagged)">
|
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="mark as spam">
|
||||||
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)">
|
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" data-tip="not spam">
|
||||||
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server">
|
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="Delete selected on server">
|
||||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection">
|
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Clear selection">
|
||||||
<i class="ph ph-x text-sm"></i>
|
<i class="ph ph-x text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
|
||||||
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
|
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
|
||||||
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
|
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
|
||||||
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
|
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" data-tip="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
|
||||||
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -218,9 +218,9 @@ function renderAccountsList() {
|
|||||||
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
|
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-account-actions">
|
<div class="email-account-actions">
|
||||||
<button class="toolbar-btn email-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button class="toolbar-btn email-edit-btn" data-tip="edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
<button class="toolbar-btn email-test-btn" data-tip="test"><i class="ph ph-plug text-sm"></i></button>
|
||||||
<button class="toolbar-btn email-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="toolbar-btn email-del-btn" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -452,13 +452,13 @@ function renderMessageList() {
|
|||||||
const spamScore = m.spam?.score ?? 0;
|
const spamScore = m.spam?.score ?? 0;
|
||||||
const spamReasons = (m.spam?.reasons || []).join(' · ');
|
const spamReasons = (m.spam?.reasons || []).join(' · ');
|
||||||
const spamBadge = spamLevel === 'spam'
|
const spamBadge = spamLevel === 'spam'
|
||||||
? `<span class="email-spam-badge spam" title="${escapeHtml(spamReasons)}">⚠ spam</span>`
|
? `<span class="email-spam-badge spam" data-tip="${escapeHtml(spamReasons)}">⚠ spam</span>`
|
||||||
: spamLevel === 'suspicious'
|
: spamLevel === 'suspicious'
|
||||||
? `<span class="email-spam-badge suspicious" title="${escapeHtml(spamReasons)}">? suspicious</span>`
|
? `<span class="email-spam-badge suspicious" data-tip="${escapeHtml(spamReasons)}">? suspicious</span>`
|
||||||
: '';
|
: '';
|
||||||
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
|
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
|
||||||
return `
|
return `
|
||||||
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" title="${escapeHtml(rowTitle)}">
|
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" data-tip="${escapeHtml(rowTitle)}">
|
||||||
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
|
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
|
||||||
<div class="email-msg-content">
|
<div class="email-msg-content">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ require_once __DIR__ . '/GiteaClient.php';
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
|
default_repo TEXT DEFAULT NULL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
");
|
");
|
||||||
|
// Migration: add default_repo column if missing
|
||||||
|
$cols = $db->query("PRAGMA table_info(gitea_accounts)")->fetchAll(PDO::FETCH_COLUMN, 1);
|
||||||
|
if (!in_array('default_repo', $cols)) {
|
||||||
|
$db->exec("ALTER TABLE gitea_accounts ADD COLUMN default_repo TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
|
||||||
@@ -85,7 +91,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
|
|
||||||
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
$rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||||
giteaJson($rows);
|
giteaJson($rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +138,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
if (!empty($input['token'])) {
|
if (!empty($input['token'])) {
|
||||||
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('default_repo', $input)) {
|
||||||
|
$val = trim((string)($input['default_repo'] ?? ''));
|
||||||
|
$fields[] = 'default_repo = :default_repo'; $params['default_repo'] = $val ?: null;
|
||||||
|
}
|
||||||
if (!$fields) giteaJson(['ok' => true]);
|
if (!$fields) giteaJson(['ok' => true]);
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||||
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
|
||||||
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,25 +40,25 @@
|
|||||||
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
||||||
<!-- Filters bar -->
|
<!-- Filters bar -->
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||||
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Issue state">
|
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Issue state">
|
||||||
<option value="open">open</option>
|
<option value="open">open</option>
|
||||||
<option value="closed">closed</option>
|
<option value="closed">closed</option>
|
||||||
<option value="all">all</option>
|
<option value="all">all</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Filter by your involvement">
|
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Filter by your involvement">
|
||||||
<option value="all">everyone</option>
|
<option value="all">everyone</option>
|
||||||
<option value="assigned">assigned to me</option>
|
<option value="assigned">assigned to me</option>
|
||||||
<option value="created">created by me</option>
|
<option value="created">created by me</option>
|
||||||
<option value="mentioned">mentioning me</option>
|
<option value="mentioned">mentioning me</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
||||||
<button id="gitea-new-issue" class="toolbar-btn" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
<button id="gitea-new-issue" class="toolbar-btn" data-tip="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
||||||
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
<button id="gitea-refresh" class="toolbar-btn" data-tip="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination toolbar -->
|
<!-- Pagination toolbar -->
|
||||||
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
||||||
<button id="gitea-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="gitea-page-prev" class="toolbar-btn" data-tip="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
<button id="gitea-page-next" class="toolbar-btn" data-tip="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
||||||
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
<button id="gitea-create-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block relative">
|
<label class="block relative">
|
||||||
@@ -140,12 +140,12 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div id="gitea-title-display" class="flex items-center gap-2">
|
<div id="gitea-title-display" class="flex items-center gap-2">
|
||||||
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
||||||
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
||||||
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
||||||
<button id="gitea-title-save" class="toolbar-btn" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
<button id="gitea-title-save" class="toolbar-btn" data-tip="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
||||||
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
<button id="gitea-title-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2 mb-2">
|
<div class="flex items-start gap-2 mb-2">
|
||||||
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
||||||
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
|
||||||
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
|
||||||
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
<button id="gitea-meta-due-clear" class="toolbar-btn" title="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
<button id="gitea-meta-due-clear" class="toolbar-btn" data-tip="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||||
<div id="gitea-body-display" class="flex items-start gap-2">
|
<div id="gitea-body-display" class="flex items-start gap-2">
|
||||||
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
||||||
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gitea-body-edit" class="hidden">
|
<div id="gitea-body-edit" class="hidden">
|
||||||
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) {
|
|||||||
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
||||||
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
</label>
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
|
||||||
|
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
</label>
|
||||||
<div class="flex items-center gap-2 pt-1">
|
<div class="flex items-center gap-2 pt-1">
|
||||||
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||||
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||||||
@@ -150,12 +154,12 @@ function renderAccountsList() {
|
|||||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||||
<div class="gitea-account-info">
|
<div class="gitea-account-info">
|
||||||
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
||||||
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div>
|
<div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gitea-account-actions">
|
<div class="gitea-account-actions">
|
||||||
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
<button class="toolbar-btn gitea-edit-btn" data-tip="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
<button class="toolbar-btn gitea-test-btn" data-tip="Test"><i class="ph ph-plug text-sm"></i></button>
|
||||||
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
<button class="toolbar-btn gitea-del-btn" data-tip="Delete"><i class="ph ph-trash text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -201,6 +205,7 @@ function resetAccountForm() {
|
|||||||
$('gitea-acc-name').value = '';
|
$('gitea-acc-name').value = '';
|
||||||
$('gitea-acc-baseurl').value = '';
|
$('gitea-acc-baseurl').value = '';
|
||||||
$('gitea-acc-token').value = '';
|
$('gitea-acc-token').value = '';
|
||||||
|
$('gitea-acc-default-repo').value = '';
|
||||||
setAccountStatus('');
|
setAccountStatus('');
|
||||||
$('gitea-tok-hint').textContent = '';
|
$('gitea-tok-hint').textContent = '';
|
||||||
}
|
}
|
||||||
@@ -214,6 +219,7 @@ function editAccount(id) {
|
|||||||
$('gitea-acc-name').value = acc.name;
|
$('gitea-acc-name').value = acc.name;
|
||||||
$('gitea-acc-baseurl').value = acc.base_url;
|
$('gitea-acc-baseurl').value = acc.base_url;
|
||||||
$('gitea-acc-token').value = '';
|
$('gitea-acc-token').value = '';
|
||||||
|
$('gitea-acc-default-repo').value = acc.default_repo || '';
|
||||||
$('gitea-tok-hint').textContent = '(leave blank to keep existing)';
|
$('gitea-tok-hint').textContent = '(leave blank to keep existing)';
|
||||||
setAccountStatus('');
|
setAccountStatus('');
|
||||||
$('gitea-acc-name').focus();
|
$('gitea-acc-name').focus();
|
||||||
@@ -240,6 +246,7 @@ async function saveAccount(e) {
|
|||||||
name: $('gitea-acc-name').value.trim(),
|
name: $('gitea-acc-name').value.trim(),
|
||||||
base_url: $('gitea-acc-baseurl').value.trim(),
|
base_url: $('gitea-acc-baseurl').value.trim(),
|
||||||
token: $('gitea-acc-token').value,
|
token: $('gitea-acc-token').value,
|
||||||
|
default_repo: $('gitea-acc-default-repo').value.trim(),
|
||||||
};
|
};
|
||||||
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
|
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
|
||||||
setAccountStatus('name, base url, and token are required', 'error');
|
setAccountStatus('name, base url, and token are required', 'error');
|
||||||
@@ -713,8 +720,9 @@ async function openCreateForm() {
|
|||||||
$('gitea-create-form').classList.remove('hidden');
|
$('gitea-create-form').classList.remove('hidden');
|
||||||
$('gitea-create-form').classList.add('flex');
|
$('gitea-create-form').classList.add('flex');
|
||||||
|
|
||||||
// Reset form
|
// Reset form — pre-fill default repo if configured
|
||||||
$('gitea-create-repo').value = '';
|
const currentAccount = accounts.find(a => a.id === currentAccountId);
|
||||||
|
$('gitea-create-repo').value = currentAccount?.default_repo || '';
|
||||||
$('gitea-create-title').value = '';
|
$('gitea-create-title').value = '';
|
||||||
$('gitea-create-body').value = '';
|
$('gitea-create-body').value = '';
|
||||||
setCreateStatus('');
|
setCreateStatus('');
|
||||||
@@ -766,6 +774,8 @@ async function openCreateForm() {
|
|||||||
errBox.classList.remove('hidden');
|
errBox.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentAccount?.default_repo) loadCreateRepoLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRepoHint() {
|
function updateRepoHint() {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@
|
|||||||
<span class="font-pixel text-[10px]">image:</span>
|
<span class="font-pixel text-[10px]">image:</span>
|
||||||
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
|
||||||
</label>
|
</label>
|
||||||
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" data-tip="pick from gallery"><i class="ph ph-images text-sm"></i></button>
|
||||||
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" data-tip="upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
|
||||||
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
|
||||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" data-tip="delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function renderImageSlide(slide) {
|
|||||||
const rects = slide.hotspots.map((h, i) => {
|
const rects = slide.hotspots.map((h, i) => {
|
||||||
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
|
||||||
const label = resolveTargetLabel(h.target) || h.target || '';
|
const label = resolveTargetLabel(h.target) || h.target || '';
|
||||||
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" title="${escHtml(label)}"></div>`;
|
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" data-tip="${escHtml(label)}"></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
viewerSlide.innerHTML = `
|
viewerSlide.innerHTML = `
|
||||||
<div class="slide-image-wrap">
|
<div class="slide-image-wrap">
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "watch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Analog clock overlay. Shows after 5 min idle or press W.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["watch.js"],
|
||||||
|
"css": ["watch.css"],
|
||||||
|
"html": ["watch.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* ── Watch overlay ── */
|
||||||
|
|
||||||
|
#watch-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9000;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
cursor: pointer; overflow: hidden;
|
||||||
|
opacity: 0; transform: scale(0.985);
|
||||||
|
transition: opacity 220ms ease, transform 220ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#watch-overlay.visible {
|
||||||
|
opacity: 1; transform: scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
#watch-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
#watch-darkening {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: #000; opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
#watch-analog {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: min(78vmin, 720px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
#watch-dial {
|
||||||
|
position: relative; width: 100%; height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.watch-layer {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.watch-layer svg { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#watch-layer-digits { z-index: 1; filter: drop-shadow(0 0 12px rgba(0,0,0,0.8)); }
|
||||||
|
#watch-layer-hour { z-index: 3; }
|
||||||
|
#watch-layer-minute { z-index: 2; }
|
||||||
|
#watch-layer-second { z-index: 4; }
|
||||||
|
|
||||||
|
#watch-second-wobble {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: transform 92ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme (default): white hands/markers */
|
||||||
|
#watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #fff;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #68c6ed);
|
||||||
|
--watch-svg-minute-fill: #fff;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #68c6ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme: black hands/markers */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
#watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #111;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #2563eb);
|
||||||
|
--watch-svg-minute-fill: #111;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #2563eb);
|
||||||
|
}
|
||||||
|
#watch-darkening { background: #fff; opacity: 0.85; }
|
||||||
|
#watch-layer-digits { filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Also detect hyprcrd's own theme mode via body class / data attribute */
|
||||||
|
body.light-theme #watch-overlay,
|
||||||
|
[data-theme-mode="light"] #watch-overlay {
|
||||||
|
--watch-svg-markers-fill: #111;
|
||||||
|
--watch-svg-hour-fill: var(--t-accent, #2563eb);
|
||||||
|
--watch-svg-minute-fill: #111;
|
||||||
|
--watch-svg-second-fill: var(--t-accent, #2563eb);
|
||||||
|
}
|
||||||
|
body.light-theme #watch-darkening,
|
||||||
|
[data-theme-mode="light"] #watch-darkening {
|
||||||
|
background: #fff; opacity: 0.85;
|
||||||
|
}
|
||||||
|
body.light-theme #watch-layer-digits,
|
||||||
|
[data-theme-mode="light"] #watch-layer-digits {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#watch-overlay { transition: none !important; }
|
||||||
|
#watch-second-wobble { transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div id="watch-overlay" class="hidden" aria-hidden="true">
|
||||||
|
<div id="watch-darkening"></div>
|
||||||
|
<div id="watch-analog">
|
||||||
|
<div id="watch-dial">
|
||||||
|
<div id="watch-layer-digits" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-hour" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-minute" class="watch-layer"></div>
|
||||||
|
<div id="watch-layer-second" class="watch-layer">
|
||||||
|
<div id="watch-second-wobble"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// ── Watch plugin — analog clock overlay ──
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const LAYER_MAP = {
|
||||||
|
digits: 'watch-layer-digits',
|
||||||
|
hour: 'watch-layer-hour',
|
||||||
|
minute: 'watch-layer-minute',
|
||||||
|
second: 'watch-second-wobble',
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible = false;
|
||||||
|
let tickTimer = null;
|
||||||
|
let idleTimer = null;
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
let showedAt = 0;
|
||||||
|
let wobbleKick = null, wobbleRebound = null;
|
||||||
|
let svgCache = {};
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
const IDLE_MINUTES = 5;
|
||||||
|
|
||||||
|
// ── SVG loading ──
|
||||||
|
|
||||||
|
async function loadSvg(layer) {
|
||||||
|
if (svgCache[layer]) return svgCache[layer];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`);
|
||||||
|
if (!res.ok) return '';
|
||||||
|
let svg = await res.text();
|
||||||
|
svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(/<!DOCTYPE[\s\S]*?>/gi, '').trim();
|
||||||
|
svgCache[layer] = svg;
|
||||||
|
return svg;
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFace() {
|
||||||
|
if (loaded) return;
|
||||||
|
for (const [layer, elId] of Object.entries(LAYER_MAP)) {
|
||||||
|
const el = $(elId);
|
||||||
|
if (el) el.innerHTML = await loadSvg(layer);
|
||||||
|
}
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clock logic ──
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = new Date();
|
||||||
|
const h12 = now.getHours() % 12;
|
||||||
|
const m = now.getMinutes();
|
||||||
|
const s = now.getSeconds();
|
||||||
|
|
||||||
|
const hourEl = $('watch-layer-hour');
|
||||||
|
const minEl = $('watch-layer-minute');
|
||||||
|
const secEl = $('watch-layer-second');
|
||||||
|
|
||||||
|
if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`;
|
||||||
|
if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`;
|
||||||
|
if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`;
|
||||||
|
|
||||||
|
triggerWobble();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerWobble() {
|
||||||
|
const wobEl = $('watch-second-wobble');
|
||||||
|
if (!wobEl) return;
|
||||||
|
clearTimeout(wobbleKick);
|
||||||
|
clearTimeout(wobbleRebound);
|
||||||
|
wobEl.style.transform = 'rotate(0deg)';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
wobEl.style.transform = 'rotate(0.78deg)';
|
||||||
|
wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95);
|
||||||
|
wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTick() {
|
||||||
|
if (tickTimer) return;
|
||||||
|
tick();
|
||||||
|
tickTimer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTick() {
|
||||||
|
if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
|
||||||
|
clearTimeout(wobbleKick);
|
||||||
|
clearTimeout(wobbleRebound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show / hide ──
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
if (visible) return;
|
||||||
|
visible = true;
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
showedAt = Date.now();
|
||||||
|
await loadFace();
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
startTick();
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
if (!visible) return;
|
||||||
|
visible = false;
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
stopTick();
|
||||||
|
setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() { visible ? hide() : show(); }
|
||||||
|
|
||||||
|
// ── Idle auto-show ──
|
||||||
|
|
||||||
|
function resetIdleTimer() {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
if (!IDLE_MINUTES) return;
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
if (visible) return;
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
if (document.querySelector('.overlay.open')) return;
|
||||||
|
show();
|
||||||
|
}, IDLE_MINUTES * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivity() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastActivity < 300) return;
|
||||||
|
lastActivity = now;
|
||||||
|
if (visible && now - showedAt > 500) hide();
|
||||||
|
resetIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const overlay = $('watch-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
overlay.addEventListener('click', () => { hide(); onActivity(); });
|
||||||
|
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && visible) {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
hide(); onActivity();
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) {
|
||||||
|
window.addEventListener(ev, onActivity, { passive: true });
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') onActivity();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetIdleTimer();
|
||||||
|
window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
window._watchToggle = toggle;
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
+15
-12
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
||||||
share one relay instance; each one has their own login, their own client
|
share one relay instance; each one has their own login, their own client
|
||||||
encryption secret, and their own inbox. Cards (and in the future:
|
encryption secret, and their own inbox. Cards and encrypted chat
|
||||||
encrypted chat messages) can be sent **between tenants** on the same
|
messages can be sent **between tenants** on the same relay.
|
||||||
relay.
|
|
||||||
|
|
||||||
The relay is a single-file PHP app — drop the directory into any PHP
|
The relay is a single-file PHP app — drop the directory into any PHP
|
||||||
webspace and it works.
|
webspace and it works.
|
||||||
@@ -55,9 +54,9 @@ The admin only ever touches secrets #1 and #3. The user only ever touches secret
|
|||||||
|
|
||||||
The relay **does** see plaintext briefly during the cross-tenant hop
|
The relay **does** see plaintext briefly during the cross-tenant hop
|
||||||
(steps 2–3 and 5–6). This is the same trust model as email: you trust
|
(steps 2–3 and 5–6). This is the same trust model as email: you trust
|
||||||
the relay operator. **Strict end-to-end encryption** for the planned
|
the relay operator. Chat between tenants is shipped (via the chat
|
||||||
chat feature is on the roadmap and will require a per-pair key exchange
|
plugin with delivery + read receipts). **Strict end-to-end encryption**
|
||||||
(Curve25519/ECDH) — this is documented as future work in `index.php`.
|
with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
|
||||||
|
|
||||||
For at-rest data, all sensitive values are encrypted with the master
|
For at-rest data, all sensitive values are encrypted with the master
|
||||||
key, so a stolen DB without the master key file gives an attacker
|
key, so a stolen DB without the master key file gives an attacker
|
||||||
@@ -155,7 +154,7 @@ incoming messages.
|
|||||||
|
|
||||||
### 6. Send a card to another tenant
|
### 6. Send a card to another tenant
|
||||||
|
|
||||||
(currently in the relay UI; hyprcrd-side recipient picker is coming)
|
(in the relay UI compose tab)
|
||||||
|
|
||||||
In the relay UI **compose** tab:
|
In the relay UI **compose** tab:
|
||||||
- Pick a recipient from the dropdown (defaults to yourself)
|
- Pick a recipient from the dropdown (defaults to yourself)
|
||||||
@@ -166,8 +165,7 @@ The recipient will see it on their next hyprcrd poll, materialized as a
|
|||||||
card in their board.
|
card in their board.
|
||||||
|
|
||||||
You can also right-click a card in hyprcrd and pick **push to relay** —
|
You can also right-click a card in hyprcrd and pick **push to relay** —
|
||||||
this currently sends to your own inbox; recipient picker on the hyprcrd
|
this sends to your own inbox (or pick a recipient in the relay UI).
|
||||||
side is on the TODO.
|
|
||||||
|
|
||||||
## Data lifetime
|
## Data lifetime
|
||||||
|
|
||||||
@@ -226,6 +224,10 @@ All endpoints under `/api/`. JSON in/out.
|
|||||||
| GET | `/api/me` | — | Current tenant info |
|
| GET | `/api/me` | — | Current tenant info |
|
||||||
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
|
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
|
||||||
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
|
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
|
||||||
|
| GET | `/api/messages/inbox` | — | Read own inbox messages (decrypted for display) |
|
||||||
|
| DELETE | `/api/messages/{id}` | — | Delete a message from own inbox |
|
||||||
|
| GET | `/api/chat/sent` | — | Sent chat messages with delivery/read status |
|
||||||
|
| POST | `/api/chat/seen` | `{ids: [...]}` | Mark chat messages as seen (read receipts) |
|
||||||
|
|
||||||
### Admin only (session + `is_admin`)
|
### Admin only (session + `is_admin`)
|
||||||
|
|
||||||
@@ -302,8 +304,7 @@ use the same code path.
|
|||||||
- A compromised relay machine = compromised messages. Use HTTPS, keep
|
- A compromised relay machine = compromised messages. Use HTTPS, keep
|
||||||
PHP up to date, restrict shell access.
|
PHP up to date, restrict shell access.
|
||||||
|
|
||||||
**Future work for stronger guarantees** (planned for the encrypted chat
|
**Future work for stronger guarantees** (roadmap for strict E2E):
|
||||||
feature):
|
|
||||||
- Per-tenant Curve25519 key pair stored in the DB (public key plus
|
- Per-tenant Curve25519 key pair stored in the DB (public key plus
|
||||||
master-key-encrypted private key, or even private key never on the
|
master-key-encrypted private key, or even private key never on the
|
||||||
server)
|
server)
|
||||||
@@ -522,7 +523,9 @@ relay/
|
|||||||
│ └── routes/
|
│ └── routes/
|
||||||
│ ├── public.php # /api/status, /setup, /login, /logout, /me
|
│ ├── public.php # /api/status, /setup, /login, /logout, /me
|
||||||
│ ├── admin.php # /api/users CRUD, /api/tenants directory
|
│ ├── admin.php # /api/users CRUD, /api/tenants directory
|
||||||
│ └── messages.php # /api/messages, /api/attachments, cross-tenant routing
|
│ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
|
||||||
|
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
|
||||||
|
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── layout.php # HTML shell + view containers
|
│ ├── layout.php # HTML shell + view containers
|
||||||
│ ├── style.css # all CSS (including responsive media queries)
|
│ ├── style.css # all CSS (including responsive media queries)
|
||||||
|
|||||||
+28
-3
@@ -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');
|
||||||
|
if (!quiet) {
|
||||||
list.innerHTML = `
|
list.innerHTML = `
|
||||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||||
<div class="skeleton-row"><div class="skel short"></div></div>
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||||
<div class="skeleton-row"><div class="skel"></div></div>
|
<div class="skeleton-row"><div class="skel"></div></div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
const res = await api('api/messages/inbox');
|
const res = await api('api/messages/inbox');
|
||||||
const messages = res?.messages || [];
|
const messages = res?.messages || [];
|
||||||
|
|
||||||
|
// In quiet mode, skip DOM update if nothing changed
|
||||||
|
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
|
||||||
|
if (quiet && json === lastInboxJson) return;
|
||||||
|
lastInboxJson = json;
|
||||||
|
|
||||||
$('inbox-count').textContent = messages.length;
|
$('inbox-count').textContent = messages.length;
|
||||||
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
|
||||||
// Mirror count into the header status so the unread dot stays in sync
|
|
||||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||||
|
|
||||||
@@ -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,6 +465,8 @@ async function deleteMessage(id, btn) {
|
|||||||
window.deleteMessage = deleteMessage;
|
window.deleteMessage = deleteMessage;
|
||||||
|
|
||||||
async function deleteAllMessages() {
|
async function deleteAllMessages() {
|
||||||
|
inboxBusy = true;
|
||||||
|
try {
|
||||||
const res = await api('api/messages/inbox');
|
const res = await api('api/messages/inbox');
|
||||||
const messages = res?.messages || [];
|
const messages = res?.messages || [];
|
||||||
if (!messages.length) return;
|
if (!messages.length) return;
|
||||||
@@ -455,6 +479,7 @@ async function deleteAllMessages() {
|
|||||||
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
|
||||||
loadInbox();
|
loadInbox();
|
||||||
refreshMe();
|
refreshMe();
|
||||||
|
} finally { inboxBusy = false; }
|
||||||
}
|
}
|
||||||
window.deleteAllMessages = deleteAllMessages;
|
window.deleteAllMessages = deleteAllMessages;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user