Compare commits
144
Commits
968d980be1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a216906da | ||
|
|
130a1c660b | ||
|
|
4baeee159e | ||
|
|
8cbec8d601 | ||
|
|
7bce6a3a36 | ||
|
|
2d58cd993e | ||
|
|
87ebcd156b | ||
|
|
4aca21812e | ||
|
|
9f90ea516a | ||
|
|
0553960a57 | ||
|
|
1e51bdec7c | ||
|
|
f986a41ccc | ||
|
|
48b5907f4c | ||
|
|
89d84d3538 | ||
|
|
29e818ac64 | ||
|
|
53ce58a5aa | ||
|
|
72a7c7c7cc | ||
|
|
1dfffa5d6d | ||
|
|
d83de30d6d | ||
|
|
d50f9fe789 | ||
|
|
8e738ed9d4 | ||
|
|
c934673c4a | ||
|
|
6fa42eb267 | ||
|
|
5c9e9254d1 | ||
|
|
59be08a8f8 | ||
|
|
be7eb1523e | ||
|
|
4c3954d989 | ||
|
|
82b2ebc15f | ||
|
|
883640e20c | ||
|
|
be28080439 | ||
|
|
c07340a3ec | ||
|
|
d052fb5f9d | ||
|
|
b9d610b612 | ||
|
|
d925070a36 | ||
|
|
e086db0c2c | ||
|
|
670f125627 | ||
|
|
c2485b1307 | ||
|
|
b08bdbaeb2 | ||
|
|
0e3088db9a | ||
|
|
29f92dbf9e | ||
|
|
d5ebbab6a5 | ||
|
|
c9749352ec | ||
|
|
412dc527a3 | ||
|
|
27cc3f98ac | ||
|
|
64be9257f2 | ||
|
|
0022f64c50 | ||
|
|
d2f7b98e6c | ||
|
|
8020ddc787 | ||
|
|
87ce038b19 | ||
|
|
ccba2932c2 | ||
|
|
f2a33a9164 | ||
|
|
41272bd9ce | ||
|
|
d31e186bd9 | ||
|
|
0ac0e34012 | ||
|
|
dce8fa4211 | ||
|
|
8cd6015cad | ||
|
|
6aa555cc6a | ||
|
|
0fd97e5d71 | ||
|
|
9267c7c6a1 | ||
|
|
52da9abd44 | ||
|
|
044caae447 | ||
|
|
618a787b36 | ||
|
|
81d9ce0e87 | ||
|
|
bfd5420981 | ||
|
|
fa3a6c2501 | ||
|
|
1a889ea435 | ||
|
|
1d6499e5dd | ||
|
|
b7f450e41f | ||
|
|
6cd00f9660 | ||
|
|
4a668b7fc4 | ||
|
|
1966f8f4c0 | ||
|
|
26f95ba079 | ||
|
|
39e94b8015 | ||
|
|
69ad2c7e71 | ||
|
|
ae974314f9 | ||
|
|
3b7c7cf5ac | ||
|
|
ca47052768 | ||
|
|
002562b1d3 | ||
|
|
3cca14a3c6 | ||
|
|
7e2b130434 | ||
|
|
2169c1de1b | ||
|
|
b9bd294c90 | ||
|
|
6c0ca4373b | ||
|
|
9f85e484c4 | ||
|
|
b9345a410f | ||
|
|
00d14b3015 | ||
|
|
f465306c1d | ||
|
|
527305355c | ||
|
|
04b52ecc19 | ||
|
|
c5bf8b0407 | ||
|
|
d5f44370b6 | ||
|
|
88ad208714 | ||
|
|
d3d9355191 | ||
|
|
ee192a15e7 | ||
|
|
22a43fdef7 | ||
|
|
bbfe968ff1 | ||
|
|
d1b26e625a | ||
|
|
838dfe2a6e | ||
|
|
3078906548 | ||
|
|
3eb5202aac | ||
|
|
c04eac06c4 | ||
|
|
bf81d046e6 | ||
|
|
195b0e343f | ||
|
|
ce6ca73ea0 | ||
|
|
088c225202 | ||
|
|
88108ecc1b | ||
|
|
b459159b0f | ||
|
|
7c113fd5ab | ||
|
|
c8835825e7 | ||
|
|
56c90225cb | ||
|
|
c16659c2ad | ||
|
|
a67bedc6f2 | ||
|
|
923de0cdd6 | ||
|
|
285e3b4abe | ||
|
|
9b4869812f | ||
|
|
7f08dc1a0d | ||
|
|
08f73dfd19 | ||
|
|
2059e50ce3 | ||
|
|
fe3399a44b | ||
|
|
f6cf7b4358 | ||
|
|
c8fe15b0c9 | ||
|
|
43c15b81a3 | ||
|
|
ccd0477841 | ||
|
|
ff3e0e8736 | ||
|
|
26ffa0e71f | ||
|
|
a29708df85 | ||
|
|
2aec2d8f44 | ||
|
|
f886ded054 | ||
|
|
22f73ab3b9 | ||
|
|
5af49655d6 | ||
|
|
3ae84ccdd7 | ||
|
|
262b853c5b | ||
|
|
988a5410f7 | ||
|
|
ef238f3b9c | ||
|
|
5b18c46ac2 | ||
|
|
7219bae655 | ||
|
|
dd1afbbeb2 | ||
|
|
86508ffcca | ||
|
|
cc97754ae6 | ||
|
|
3f4ba3c0f4 | ||
|
|
1545b90f39 | ||
|
|
dd2a995f6c | ||
|
|
0d494bf507 | ||
|
|
b5e30afd0b |
No files matched your search
@@ -8,3 +8,11 @@
|
|||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.zip
|
*.zip
|
||||||
|
electron/node_modules
|
||||||
|
data/hyprcrd.db-shm
|
||||||
|
data/hyprcrd.db-wal
|
||||||
|
|
||||||
|
/oumta-dashboard/
|
||||||
|
/oumta-dashboard-ui/
|
||||||
|
|
||||||
|
relay/data
|
||||||
@@ -23,6 +23,22 @@ Open `http://localhost:8080` and set your password on first visit.
|
|||||||
|
|
||||||
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
||||||
|
|
||||||
|
### Via docker compose
|
||||||
|
|
||||||
|
If you prefer compose, the bundled `docker-compose.yml` starts the app alone by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # just the app on :8088
|
||||||
|
```
|
||||||
|
|
||||||
|
To also bring up a local relay (for multi-tenant messaging / collab / chat), opt in via the `relay` profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile relay up -d # app on :8088 + relay on :8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Most installs talk to a remote relay, so the app-only default keeps the local setup minimal.
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|
||||||
### 1. Clone
|
### 1. Clone
|
||||||
@@ -89,8 +105,9 @@ hyprcrd/
|
|||||||
├── docker-entrypoint.sh
|
├── docker-entrypoint.sh
|
||||||
├── dist.sh # Build distribution ZIP
|
├── dist.sh # Build distribution ZIP
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── bootstrap.php # Initialization
|
│ ├── bootstrap.php # Initialization + plugin discovery
|
||||||
│ ├── Database.php # SQLite schema & migrations
|
│ ├── Database.php # SQLite schema & migrations
|
||||||
|
│ ├── PluginManager.php # Plugin scanner & asset/slot renderer
|
||||||
│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser
|
│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser
|
||||||
│ └── CalDavSync.php # Bidirectional CalDAV sync engine
|
│ └── CalDavSync.php # Bidirectional CalDAV sync engine
|
||||||
├── public/ # Document root
|
├── public/ # Document root
|
||||||
@@ -103,12 +120,29 @@ hyprcrd/
|
|||||||
│ │ └── vendor/ # Phosphor Icons CSS + fonts
|
│ │ └── vendor/ # Phosphor Icons CSS + fonts
|
||||||
│ ├── fonts/ # JetBrains Mono & Silkscreen
|
│ ├── fonts/ # JetBrains Mono & Silkscreen
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
|
│ │ ├── hyprcrd.js # Plugin API (window.hyprcrd, sync-loaded)
|
||||||
│ │ ├── app.js # Orchestrator
|
│ │ ├── app.js # Orchestrator
|
||||||
│ │ ├── modules/ # Feature modules
|
│ │ ├── modules/ # Core feature modules
|
||||||
│ │ └── vendor/ # marked.js, DOMPurify
|
│ │ └── vendor/ # marked.js, DOMPurify
|
||||||
|
│ ├── plugins/ # Optional, drop-in plugin folders
|
||||||
|
│ │ ├── 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
|
||||||
│ └── 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
|
||||||
@@ -121,9 +155,9 @@ hyprcrd/
|
|||||||
- **Cards** with markdown body, color, tags, and position
|
- **Cards** with markdown body, color, tags, and position
|
||||||
- **Buckets** for organizing cards into groups
|
- **Buckets** for organizing cards into groups
|
||||||
- **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
|
- **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
|
||||||
@@ -136,7 +170,115 @@ 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
|
||||||
|
|
||||||
|
hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plugins/` with a `plugin.json` manifest. The core scans the directory at startup, loads each plugin's CSS/JS/HTML, and the plugin self-registers against `window.hyprcrd`. Plugins are entirely optional — removing a plugin folder removes the feature, with no code changes anywhere else.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **`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
|
||||||
|
|
||||||
|
```
|
||||||
|
public/plugins/myplugin/
|
||||||
|
├── plugin.json # Manifest (required)
|
||||||
|
├── myplugin.js # Entry point (loaded as ES module)
|
||||||
|
├── myplugin.css # Optional styles
|
||||||
|
├── myplugin.html # Optional HTML fragment (overlays, modals, etc.)
|
||||||
|
└── backend.php # Optional PHP hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
**`plugin.json`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "myplugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "What this plugin does",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["myplugin.js"],
|
||||||
|
"css": ["myplugin.css"],
|
||||||
|
"html": ["myplugin.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin API (`window.hyprcrd`)
|
||||||
|
|
||||||
|
Loaded synchronously before any module, so plugins can register handlers immediately:
|
||||||
|
|
||||||
|
| Namespace | Methods | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `hyprcrd.editor` | `registerCommand(name, fn)`, `getCommand(name)`, `api.{body, insertMd, updatePreview}` | Add toolbar commands. The `data-md="<name>"` button dispatches to the registered handler. `api` is set after the editor initializes. |
|
||||||
|
| `hyprcrd.markdown` | `registerFencedBlock(lang, fn)`, `registerPreProcessor(fn)`, `registerPostProcessor(fn)` | Add ` ```<lang> ` block renderers (returns HTML, passes through DOMPurify). Pre/post processors run on the source/HTML around `marked.parse()`. |
|
||||||
|
| `hyprcrd.hooks` | `on(name, fn)`, `off(name, fn)`, `emit(name, data)` | Generic event bus. Use for cross-plugin or core→plugin signaling. |
|
||||||
|
| `hyprcrd.util` | `$(idOrSel)`, `$$(sel)`, `esc(s)` | Lightweight DOM/escape helpers so plugins don't need to import core modules. |
|
||||||
|
| `hyprcrd.registerPlugin(meta)` | — | Register the plugin's metadata for introspection. |
|
||||||
|
|
||||||
|
### Minimal plugin example
|
||||||
|
|
||||||
|
`public/plugins/hello/plugin.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hello",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Inserts a greeting",
|
||||||
|
"frontend": { "js": ["hello.js"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`public/plugins/hello/hello.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
|
||||||
|
h.editor.registerCommand('hello', () => {
|
||||||
|
h.editor.api.insertMd('Hello, world! ', '', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject a toolbar button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.dataset.md = 'hello';
|
||||||
|
btn.className = 'toolbar-btn';
|
||||||
|
btn.title = 'Hello';
|
||||||
|
btn.textContent = '👋';
|
||||||
|
document.getElementById('md-toolbar').appendChild(btn);
|
||||||
|
|
||||||
|
h.registerPlugin({ name: 'hello', version: '1.0.0' });
|
||||||
|
```
|
||||||
|
|
||||||
|
That's the entire plugin. Drop the folder in, reload, the button appears.
|
||||||
|
|
||||||
|
### Backend hooks (PHP)
|
||||||
|
|
||||||
|
If a plugin needs server-side code, set `"backend": "backend.php"` in the manifest. The file is `require_once`'d during bootstrap. Backend plugins can use `PluginManager::instance()->addToSlot('slot-name', '<html>')` to inject HTML into named slots in `index.php` (e.g. `md-toolbar`).
|
||||||
|
|
||||||
|
### Removing a plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf public/plugins/canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The toolbar button, overlay, CSS, and markdown handler all disappear together. The core has no awareness of any specific plugin.
|
||||||
|
|
||||||
## Data & Backup
|
## Data & Backup
|
||||||
|
|
||||||
@@ -242,30 +384,37 @@ When running multiple hyprcrd instances that share CalDAV credentials, you need
|
|||||||
|
|
||||||
## Relay
|
## Relay
|
||||||
|
|
||||||
A minimal web service for mobile note capture and card sharing between hyprcrd instances.
|
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
|
||||||
|
share one relay instance — each one has their own login, their own
|
||||||
|
client encryption secret, and their own inbox. Cards (and in the future:
|
||||||
|
encrypted chat messages) can be sent **between tenants** on the same
|
||||||
|
relay.
|
||||||
|
|
||||||
|
**Full documentation:** [`relay/README.md`](relay/README.md)
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
[Phone/Browser] → https://relay.example.com → [Relay Service]
|
[hyprcrd alice] ──push──┐ ┌──poll── [hyprcrd alice]
|
||||||
↓ poll
|
├──► [Relay] ◄────────────┤
|
||||||
[hyprcrd (local)]
|
[hyprcrd bob] ──push──┘ tenants: alice, bob └──poll── [hyprcrd bob]
|
||||||
|
|
||||||
[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B]
|
sender encrypts → relay decrypts with sender's secret →
|
||||||
|
re-encrypts with shared inter-tenant key → stores →
|
||||||
|
on recipient poll: decrypts → re-encrypts with recipient's secret
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to Webspace
|
### Deploy
|
||||||
|
|
||||||
|
**Webspace** (any PHP host with `mod_rewrite` and `pdo_sqlite`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd relay
|
cd relay
|
||||||
./dist.sh
|
./dist.sh
|
||||||
|
# upload hyprcrd-relay.zip and extract into your webspace
|
||||||
```
|
```
|
||||||
|
|
||||||
Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret.
|
**Docker**:
|
||||||
|
|
||||||
Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`.
|
|
||||||
|
|
||||||
### Deploy with Docker
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd relay
|
cd relay
|
||||||
@@ -273,42 +422,97 @@ docker build -t hyprcrd-relay .
|
|||||||
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
|
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `data/` directory is created on first request and contains the
|
||||||
|
SQLite DB plus the master encryption key (chmod 600, blocked from web
|
||||||
|
access via `.htaccess`).
|
||||||
|
|
||||||
### Development (both together)
|
### Development (both together)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dev.sh
|
./dev.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding.
|
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
||||||
|
|
||||||
From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
Under the hood this runs `docker compose --profile relay up --build`. The relay service lives behind the `relay` compose profile, so a plain `docker compose up` brings up only the app — handy when you're developing against a remote relay.
|
||||||
|
|
||||||
### Setup
|
### Setup workflow
|
||||||
|
|
||||||
1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption)
|
1. Open the relay URL **from a desktop browser** — the setup wizard
|
||||||
2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret
|
is desktop-friendly
|
||||||
3. Click **save**
|
2. Create the first **admin** account: username + password (UI login)
|
||||||
|
+ shared secret (client encryption)
|
||||||
|
3. As admin, click the **users** tab and create accounts for any
|
||||||
|
additional users you want to give relay access to
|
||||||
|
4. Each user enters their credentials in their own hyprcrd:
|
||||||
|
**Settings → Relay** → relay URL + username + shared secret
|
||||||
|
5. To send a card from one tenant to another, use the relay UI's
|
||||||
|
compose form (recipient picker) — or push cards from hyprcrd
|
||||||
|
|
||||||
### How it works
|
### How it works (high level)
|
||||||
|
|
||||||
- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag
|
- **Per-tenant inboxes**: each user only sees their own messages
|
||||||
- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll
|
- **Cross-tenant routing**: send a card to another tenant on the same
|
||||||
- Messages are encrypted client-side (AES-256-CBC) before sending to the relay
|
relay; the relay decrypts with the sender's secret, re-encrypts with
|
||||||
- The relay only stores ciphertext — it cannot read your messages
|
a shared inter-tenant key for storage, then re-encrypts with the
|
||||||
- hyprcrd polls the relay every 45 seconds in the background
|
recipient's secret on delivery
|
||||||
- Messages are kept on the relay for 24 hours, then auto-deleted
|
- **At-rest encryption**: every sensitive value in the DB (tenant
|
||||||
- Cards pushed from the same instance are not re-imported
|
secrets, the inter-tenant key, message bodies) is encrypted. The
|
||||||
- Enter to send, Shift+Enter for newline
|
master key lives outside the DB in a chmod-600 file
|
||||||
|
- **24-hour ephemeral**: messages and attachments auto-delete after a
|
||||||
|
day. Cards extracted in hyprcrd are the long-term store
|
||||||
|
- **Encrypted chat**: tenant-to-tenant chat with delivery + read receipts
|
||||||
|
(via chat plugin). Per-pair E2E key exchange is on the roadmap
|
||||||
|
|
||||||
### Relay Security
|
### Relay Security
|
||||||
|
|
||||||
- Separate password (UI login) and shared secret (API + encryption)
|
- Per-tenant bcrypt-hashed passwords + secrets
|
||||||
- Bcrypt hashing for both credentials
|
- Master key encryption for tenant secrets and the inter-tenant key
|
||||||
- Rate-limited login (5 attempts/minute)
|
at rest
|
||||||
- AES-256-CBC client-side encryption — relay never sees plaintext
|
- HttpOnly + SameSite=Strict session cookies
|
||||||
|
- Rate-limited login (5 attempts/minute per IP)
|
||||||
|
- AES-256-CBC for all message encryption
|
||||||
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
|
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
|
||||||
- SQLite database blocked from direct download via `.htaccess`
|
- `data/` directory blocked from direct download via `.htaccess`
|
||||||
- Request size limited to 256KB
|
- Request size limited to 256KB (15MB for attachments)
|
||||||
|
- Last-admin protection prevents accidental lockout
|
||||||
|
|
||||||
|
For the full security model (including what's NOT protected and the
|
||||||
|
roadmap to strict E2E for chat), see [`relay/README.md`](relay/README.md).
|
||||||
|
|
||||||
|
### Migrating from the old single-tenant relay
|
||||||
|
|
||||||
|
When you upgrade an existing relay installation, the new code automatically
|
||||||
|
detects the legacy schema and drops the old `messages` and `attachments`
|
||||||
|
tables (the data is 24h-ephemeral anyway). The setup wizard appears on
|
||||||
|
your next visit. You'll need to:
|
||||||
|
1. Run through the desktop setup wizard to create the first admin
|
||||||
|
2. Recreate any other tenant accounts that need access
|
||||||
|
3. On every connected hyprcrd, update **Settings → Relay** to add the
|
||||||
|
new **username** field and the new shared secret
|
||||||
|
|
||||||
|
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
|
||||||
|
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
|
||||||
|
|
||||||
@@ -318,8 +522,8 @@ After changing Tailwind utility classes in HTML or JS files:
|
|||||||
# Download standalone CLI (once)
|
# Download standalone CLI (once)
|
||||||
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss && chmod +x tailwindcss
|
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss && chmod +x tailwindcss
|
||||||
|
|
||||||
# Build
|
# Build (includes plugins so plugin HTML/JS contributes utility classes)
|
||||||
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js" --minify
|
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js,public/plugins/**/*.{js,html}" --minify
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bump version in config.php + electron/package.json
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
|
echo "Usage: ./bump.sh [--patch|--minor|--major]"
|
||||||
|
echo ""
|
||||||
|
echo " --patch bump patch version (default) 1.3.5 → 1.3.6"
|
||||||
|
echo " --minor bump minor version 1.3.6 → 1.4.0"
|
||||||
|
echo " --major bump major version 1.4.0 → 2.0.0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
|
|
||||||
|
case "${1:---patch}" in
|
||||||
|
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||||
|
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||||
|
*) PATCH=$((PATCH + 1)) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
|
||||||
|
# config.php
|
||||||
|
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
||||||
|
|
||||||
|
# electron/package.json
|
||||||
|
if [ -f electron/package.json ]; then
|
||||||
|
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${VERSION}\"/" electron/package.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
# website footer
|
||||||
|
if [ -f website/index.html ]; then
|
||||||
|
sed -i "s/v${CURRENT}/v${VERSION}/g" website/index.html
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${CURRENT} → ${VERSION}"
|
||||||
+16
-2
@@ -1,10 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.7.1');
|
define('APP_VERSION', '2.0.16');
|
||||||
|
|
||||||
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
|
define('APP_DEBUG', getenv('APP_DEBUG') === '1' || getenv('APP_DEBUG') === 'true');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
if (!defined('DB_PATH')) define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
if (!defined('UPLOAD_DIR')) define('UPLOAD_DIR', __DIR__ . '/public/uploads');
|
||||||
|
|
||||||
// Base URL auto-detection (works in Docker and hosted environments)
|
// Base URL auto-detection (works in Docker and hosted environments)
|
||||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
@@ -12,3 +18,11 @@ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|||||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
||||||
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
||||||
|
|
||||||
|
// Oumta dashboard upstream. The browser only talks to /api/dashboard/*
|
||||||
|
// same-origin; the PHP proxy below forwards to this URL. Override via env
|
||||||
|
// for remote deployments. Inside Docker on Linux, localhost points at the
|
||||||
|
// container itself — use http://host.docker.internal:8085 (needs
|
||||||
|
// extra_hosts) or a routable URL to reach a host-side dashboard.
|
||||||
|
define('OUMTA_DASHBOARD_URL', getenv('OUMTA_DASHBOARD_URL') ?: 'http://localhost:8085');
|
||||||
|
define('OUMTA_DASHBOARD_TOKEN', getenv('OUMTA_DASHBOARD_TOKEN') ?: '');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"disabled": [
|
||||||
|
"logseq-import",
|
||||||
|
"example-content"
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Tear down the full dev stack and wipe the support-relay data (DB +
|
||||||
|
# master key). Hyprcrd and the main relay keep their data — this is
|
||||||
|
# meant for iterating on support-relay setup / API-key rotation.
|
||||||
|
#
|
||||||
|
# Usage: ./dev-down.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# If the stand-alone support-relay/docker-compose.yml was used earlier
|
||||||
|
# (before we folded it into the main compose), stop that too.
|
||||||
|
if [ -f support-relay/docker-compose.yml ]; then
|
||||||
|
( cd support-relay && docker compose down --remove-orphans ) 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop main dev stack across all profiles. `down` without -v preserves
|
||||||
|
# the named volumes (hyprcrd + relay data).
|
||||||
|
docker compose --profile relay --profile support down --remove-orphans
|
||||||
|
|
||||||
|
# Wipe support-relay storage — both the compose-managed named volume
|
||||||
|
# and the stand-alone bind-mount location. Match volumes by suffix so
|
||||||
|
# whatever compose-project-name was in effect (hyprcrd / something else)
|
||||||
|
# we find it.
|
||||||
|
for vol in $(docker volume ls -q | grep -E '(^|_)support-relay-data$' || true); do
|
||||||
|
docker volume rm -f "$vol" >/dev/null
|
||||||
|
echo " wiped volume: $vol"
|
||||||
|
done
|
||||||
|
if [ -d support-relay/data ]; then
|
||||||
|
rm -f support-relay/data/*.db support-relay/data/*.db-* support-relay/data/master.key 2>/dev/null || true
|
||||||
|
echo " wiped bind-mount: support-relay/data/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " all containers stopped."
|
||||||
|
echo " support-relay data gone — next boot runs the setup wizard again"
|
||||||
|
echo " and mints a fresh API key."
|
||||||
|
echo " hyprcrd + relay data preserved."
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Start hyprcrd + relay for local development
|
# Start hyprcrd + relay + support-relay for local development. Both
|
||||||
|
# auxiliary services sit behind compose profiles — this script opts
|
||||||
|
# into all of them so a dev can exercise the full sync + support flows
|
||||||
|
# without juggling profile flags.
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
docker compose up -d --build
|
#docker compose --profile relay --profile support up -d --build
|
||||||
|
docker compose --profile support up -d --build
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " hyprcrd: http://localhost:8088"
|
echo " hyprcrd: http://localhost:8088"
|
||||||
echo " relay: http://localhost:8081"
|
#echo " relay: http://localhost:8081"
|
||||||
|
echo " support-relay: http://localhost:8090"
|
||||||
echo ""
|
echo ""
|
||||||
echo " relay from hyprcrd container: http://relay:80"
|
#echo " relay from hyprcrd container: http://relay:80"
|
||||||
|
echo " support-relay from hyprcrd container: http://support-relay:80"
|
||||||
|
echo ""
|
||||||
|
echo " (for app-only, drop the profiles: docker compose up -d)"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1,35 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Build hyprcrd distribution ZIP with version bump + minification
|
# Build hyprcrd distribution ZIP (no version bump — use ./bump.sh first)
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
echo "Usage: ./dist.sh [--patch|--minor|--major]"
|
echo "Usage: ./dist.sh"
|
||||||
echo ""
|
echo ""
|
||||||
echo " --patch bump patch version (default) 0.1.1 → 0.1.2"
|
echo "Builds Tailwind CSS, minifies JS/CSS, and creates"
|
||||||
echo " --minor bump minor version 0.1.2 → 0.2.0"
|
echo "hyprcrd-{version}.zip ready for deployment."
|
||||||
echo " --major bump major version 0.2.0 → 1.0.0"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php,"
|
echo "To bump the version first: ./bump.sh [--patch|--minor|--major]"
|
||||||
echo "and creates hyprcrd-{version}.zip ready for deployment."
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Read current version
|
VERSION=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
||||||
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
|
echo "Building v${VERSION}"
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
|
||||||
|
|
||||||
# Bump: --major, --minor, or patch (default)
|
|
||||||
case "${1:-patch}" in
|
|
||||||
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
|
||||||
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
|
||||||
*) PATCH=$((PATCH + 1)) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
|
||||||
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
|
|
||||||
echo "Version: ${CURRENT} → ${VERSION}"
|
|
||||||
|
|
||||||
DIST="hyprcrd-${VERSION}.zip"
|
DIST="hyprcrd-${VERSION}.zip"
|
||||||
STAGE="/tmp/hyprcrd-stage-$$"
|
STAGE="/tmp/hyprcrd-stage-$$"
|
||||||
|
|||||||
@@ -3,21 +3,50 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8088:80"
|
- "8088:80"
|
||||||
|
environment:
|
||||||
|
# `host.docker.internal` resolves to the Docker host via the
|
||||||
|
# extra_hosts mapping below, so the PHP proxy can reach a dashboard
|
||||||
|
# running directly on the host. Swap to a routable URL when the
|
||||||
|
# dashboard moves to a remote server.
|
||||||
|
OUMTA_DASHBOARD_URL: http://host.docker.internal:8085
|
||||||
|
# OUMTA_DASHBOARD_TOKEN: "" # Keycloak bearer, when auth is on
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/var/www/html/data
|
- ./data:/var/www/html/data
|
||||||
- ./src:/var/www/html/src
|
- ./src:/var/www/html/src
|
||||||
- ./public:/var/www/html/public
|
- ./public:/var/www/html/public
|
||||||
- ./config.php:/var/www/html/config.php
|
- ./config.php:/var/www/html/config.php
|
||||||
|
|
||||||
|
# Relay is opt-in via the `relay` profile. Plain `docker compose up`
|
||||||
|
# starts just the app; use `docker compose --profile relay up` when
|
||||||
|
# you want to develop against a local relay instead of a remote one.
|
||||||
relay:
|
relay:
|
||||||
build: ./relay
|
build: ./relay
|
||||||
|
profiles: ["relay"]
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./relay/index.php:/var/www/html/index.php
|
- ./relay/index.php:/var/www/html/index.php
|
||||||
- ./relay/.htaccess:/var/www/html/.htaccess
|
- ./relay/.htaccess:/var/www/html/.htaccess
|
||||||
- ./relay/fonts:/var/www/html/fonts
|
- ./relay/fonts:/var/www/html/fonts
|
||||||
|
- ./relay/inc:/var/www/html/inc
|
||||||
|
- ./relay/ui:/var/www/html/ui
|
||||||
- relay-data:/var/www/html/data
|
- relay-data:/var/www/html/data
|
||||||
|
|
||||||
|
# Support-relay also opt-in via its own profile. Shares the compose
|
||||||
|
# network with the app so hyprcrd's support plugin can reach it at
|
||||||
|
# `http://support-relay:80` (container-to-container, no host port
|
||||||
|
# needed). Port 8090 is still published so you can open the public
|
||||||
|
# form in a browser.
|
||||||
|
support-relay:
|
||||||
|
build: ./support-relay
|
||||||
|
profiles: ["support"]
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
volumes:
|
||||||
|
- support-relay-data:/var/www/html/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
relay-data:
|
relay-data:
|
||||||
|
support-relay-data:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
appdata/
|
||||||
|
php-bin/linux/php
|
||||||
|
php-bin/darwin/php
|
||||||
|
php-bin/win32/php.exe
|
||||||
|
php-bin/win32/*.dll
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# hyprcrd — Electron Desktop App
|
||||||
|
|
||||||
|
Wraps the PHP backend in an Electron shell. A static PHP binary is bundled
|
||||||
|
per platform so users don't need PHP installed.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. On launch, the app starts PHP's built-in server on a random port (`127.0.0.1`)
|
||||||
|
2. `router.php` replaces Apache's `.htaccess` rewrite rules
|
||||||
|
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
|
||||||
|
4. On quit, the PHP process is killed cleanly
|
||||||
|
|
||||||
|
User data (database + uploads) is stored **outside** the app bundle so it
|
||||||
|
survives updates:
|
||||||
|
|
||||||
|
- **Dev**: `electron/appdata/`
|
||||||
|
- **Linux**: `~/.config/hyprcrd/data/`
|
||||||
|
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
|
||||||
|
- **Windows**: `%APPDATA%/hyprcrd/data/`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd electron
|
||||||
|
npm install
|
||||||
|
npm start # launches the app using system or bundled PHP
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires PHP 8.1+ with `pdo_sqlite` and `zip` extensions — either on
|
||||||
|
the system PATH or as a static binary in `php-bin/<platform>/`.
|
||||||
|
|
||||||
|
## Bundling PHP
|
||||||
|
|
||||||
|
Static PHP binaries are not checked into git. Fetch them before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fetch-php.sh # downloads ~20MB per platform from static-php-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Or place them manually (see `php-bin/README.md` for sources):
|
||||||
|
|
||||||
|
php-bin/linux/php
|
||||||
|
php-bin/darwin/php
|
||||||
|
php-bin/win32/php.exe
|
||||||
|
|
||||||
|
The app prefers the bundled binary. If none is found, it falls back to
|
||||||
|
system PHP. If neither exists, a dialog tells the user what to install.
|
||||||
|
|
||||||
|
## Building distributables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `electron-builder`. Output:
|
||||||
|
|
||||||
|
| Platform | Format | Location |
|
||||||
|
|----------|----------|-------------------|
|
||||||
|
| macOS | `.dmg` | `dist/` |
|
||||||
|
| Windows | NSIS | `dist/` |
|
||||||
|
| Linux | AppImage | `dist/` |
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
electron/
|
||||||
|
main.js Electron main process — spawns PHP, creates window
|
||||||
|
preload.js Exposes window.hyprcrdElectron (platform, isElectron)
|
||||||
|
router.php PHP built-in server router (replaces .htaccess)
|
||||||
|
fetch-php.sh Downloads static PHP binaries for all platforms
|
||||||
|
php-bin/ Platform-specific PHP binaries (gitignored)
|
||||||
|
```
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download static PHP binaries for all platforms.
|
||||||
|
# Run from the electron/ directory.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/php-bin"
|
||||||
|
|
||||||
|
BASE="https://dl.static-php.dev/static-php-cli/common"
|
||||||
|
|
||||||
|
# Find latest 8.3.x version
|
||||||
|
echo "Finding latest PHP 8.3 version..."
|
||||||
|
VERSION=$(curl -sL "$BASE/" | grep -oP 'php-8\.3\.\d+-cli-linux-x86_64' | sort -V | tail -1 | grep -oP '8\.3\.\d+')
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "ERROR: Could not determine latest PHP 8.3 version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Using PHP $VERSION"
|
||||||
|
|
||||||
|
# Linux x86_64
|
||||||
|
echo " linux..."
|
||||||
|
curl -fSL "$BASE/php-${VERSION}-cli-linux-x86_64.tar.gz" -o /tmp/php-linux.tar.gz
|
||||||
|
tar -xzf /tmp/php-linux.tar.gz -C linux/
|
||||||
|
chmod +x linux/php
|
||||||
|
rm /tmp/php-linux.tar.gz
|
||||||
|
|
||||||
|
# macOS arm64
|
||||||
|
echo " darwin..."
|
||||||
|
curl -fSL "$BASE/php-${VERSION}-cli-macos-aarch64.tar.gz" -o /tmp/php-darwin.tar.gz
|
||||||
|
tar -xzf /tmp/php-darwin.tar.gz -C darwin/
|
||||||
|
chmod +x darwin/php
|
||||||
|
rm /tmp/php-darwin.tar.gz
|
||||||
|
|
||||||
|
# Windows — static-php-cli doesn't ship Windows builds,
|
||||||
|
# so we skip it here. For Windows, bundle PHP from https://windows.php.net/download/
|
||||||
|
echo " win32: skipped (no static build available — use windows.php.net)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done: PHP $VERSION"
|
||||||
|
ls -lh linux/php darwin/php 2>/dev/null
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
const { app, BrowserWindow, dialog, shell } = require('electron');
|
||||||
|
const { spawn, execFileSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let phpProcess = null;
|
||||||
|
let mainWindow = null;
|
||||||
|
let serverPort = 0;
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
|
||||||
|
function resolvePath(rel) {
|
||||||
|
if (isDev) return path.join(__dirname, '..', rel);
|
||||||
|
return path.join(process.resourcesPath, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBundledPhp() {
|
||||||
|
const exe = process.platform === 'win32' ? 'php.exe' : 'php';
|
||||||
|
const bundled = isDev
|
||||||
|
? path.join(__dirname, 'php-bin', process.platform, exe)
|
||||||
|
: path.join(process.resourcesPath, 'php-bin', exe);
|
||||||
|
if (fs.existsSync(bundled)) {
|
||||||
|
try {
|
||||||
|
execFileSync(bundled, ['-v'], { stdio: 'ignore', timeout: 3000 });
|
||||||
|
return bundled;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSystemPhp() {
|
||||||
|
const candidates = process.platform === 'win32'
|
||||||
|
? ['php.exe', 'C:\\php\\php.exe', 'C:\\xampp\\php\\php.exe']
|
||||||
|
: ['php', '/usr/bin/php', '/usr/local/bin/php', '/opt/homebrew/bin/php'];
|
||||||
|
for (const bin of candidates) {
|
||||||
|
try {
|
||||||
|
execFileSync(bin, ['-v'], { stdio: 'ignore', timeout: 3000 });
|
||||||
|
return bin;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPhp() {
|
||||||
|
return findBundledPhp() || findSystemPhp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPhpExtensions(phpBin) {
|
||||||
|
try {
|
||||||
|
const mods = execFileSync(phpBin, ['-m'], { timeout: 3000 }).toString().toLowerCase();
|
||||||
|
const missing = [];
|
||||||
|
for (const ext of ['pdo_sqlite', 'zip']) {
|
||||||
|
if (!mods.includes(ext)) missing.push(ext);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer();
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = srv.address().port;
|
||||||
|
srv.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
srv.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataDir() {
|
||||||
|
if (isDev) return path.join(__dirname, 'appdata');
|
||||||
|
return path.join(app.getPath('userData'), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDataDir() {
|
||||||
|
const dataDir = getDataDir();
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
return dataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPhpServer() {
|
||||||
|
const phpBin = findPhp();
|
||||||
|
if (!phpBin) {
|
||||||
|
dialog.showErrorBox('PHP not found',
|
||||||
|
'hyprcrd needs PHP 8.1+ with pdo_sqlite.\n\n' +
|
||||||
|
'Install PHP and make sure it\'s in your PATH.\n\n' +
|
||||||
|
(process.platform === 'darwin' ? 'brew install php' :
|
||||||
|
process.platform === 'win32' ? 'https://windows.php.net/download/' :
|
||||||
|
'sudo apt install php php-sqlite3'));
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = checkPhpExtensions(phpBin);
|
||||||
|
if (missing.length) {
|
||||||
|
dialog.showErrorBox('Missing PHP extensions',
|
||||||
|
`hyprcrd needs these PHP extensions: ${missing.join(', ')}\n\n` +
|
||||||
|
(process.platform === 'darwin' ? 'They are included with "brew install php".' :
|
||||||
|
process.platform === 'win32' ? 'Enable them in php.ini (uncomment extension=...).' :
|
||||||
|
`Install with: sudo apt install ${missing.map(e => 'php-' + e.replace('pdo_', '')).join(' ')}`));
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = ensureDataDir();
|
||||||
|
serverPort = await getFreePort();
|
||||||
|
|
||||||
|
const docRoot = resolvePath('public');
|
||||||
|
const routerFile = isDev
|
||||||
|
? path.join(__dirname, 'router.php')
|
||||||
|
: path.join(process.resourcesPath, 'router.php');
|
||||||
|
|
||||||
|
console.log('[hyprcrd] PHP:', phpBin);
|
||||||
|
console.log('[hyprcrd] docRoot:', docRoot);
|
||||||
|
console.log('[hyprcrd] router:', routerFile);
|
||||||
|
console.log('[hyprcrd] data:', dataDir);
|
||||||
|
console.log('[hyprcrd] port:', serverPort);
|
||||||
|
|
||||||
|
phpProcess = spawn(phpBin, [
|
||||||
|
'-d', 'upload_max_filesize=64M',
|
||||||
|
'-d', 'post_max_size=66M',
|
||||||
|
'-d', 'display_errors=0',
|
||||||
|
'-S', `127.0.0.1:${serverPort}`,
|
||||||
|
'-t', docRoot,
|
||||||
|
routerFile,
|
||||||
|
], {
|
||||||
|
cwd: resolvePath('.'),
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, PHP_CLI_SERVER_WORKERS: '2', HYPRCRD_DATA_DIR: dataDir },
|
||||||
|
});
|
||||||
|
|
||||||
|
phpProcess.stderr.on('data', (d) => {
|
||||||
|
const line = d.toString().trim();
|
||||||
|
if (line && !line.includes('Accepted') && !line.includes('Closing'))
|
||||||
|
console.log('[php]', line);
|
||||||
|
});
|
||||||
|
|
||||||
|
phpProcess.on('exit', (code) => {
|
||||||
|
console.log(`[php] exited with code ${code}`);
|
||||||
|
phpProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the server to accept connections
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const ok = await new Promise(resolve => {
|
||||||
|
const sock = net.createConnection(serverPort, '127.0.0.1');
|
||||||
|
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
||||||
|
sock.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
if (ok) return serverPort;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.showErrorBox('Server failed', 'PHP server did not start in time.');
|
||||||
|
app.quit();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 1150,
|
||||||
|
minHeight: 500,
|
||||||
|
title: 'hyprcrd',
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadURL(`http://127.0.0.1:${serverPort}/`);
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-fail-load', (e, code, desc) => {
|
||||||
|
console.log(`[hyprcrd] load failed: ${code} ${desc}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http://127.0.0.1')) return { action: 'allow' };
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('before-input-event', (e, input) => {
|
||||||
|
if (input.key === 'F12') mainWindow.webContents.toggleDevTools();
|
||||||
|
if (input.key === 'r' && (input.control || input.meta)) mainWindow.webContents.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => { mainWindow = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('ready', async () => {
|
||||||
|
const port = await startPhpServer();
|
||||||
|
if (port) createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => { app.quit(); });
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
if (phpProcess) {
|
||||||
|
phpProcess.kill('SIGTERM');
|
||||||
|
phpProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (!mainWindow && serverPort) createWindow();
|
||||||
|
});
|
||||||
Generated
+4896
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "hyprcrd",
|
||||||
|
"version": "2.0.14",
|
||||||
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dist": "electron-builder"
|
||||||
|
},
|
||||||
|
"author": "th",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^41.2.0",
|
||||||
|
"electron-builder": "^26.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.hyprcrd.app",
|
||||||
|
"productName": "hyprcrd",
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "router.php",
|
||||||
|
"to": "router.php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../public",
|
||||||
|
"to": "public",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../src",
|
||||||
|
"to": "src",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../config.php",
|
||||||
|
"to": "config.php"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "../data",
|
||||||
|
"to": "data",
|
||||||
|
"filter": [
|
||||||
|
".gitkeep"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"category": "public.app-category.productivity",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/darwin",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/win32",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"category": "Office",
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "php-bin/linux",
|
||||||
|
"to": "php-bin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
linux/php
|
||||||
|
darwin/php
|
||||||
|
win32/php.exe
|
||||||
|
win32/*.dll
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Bundled PHP binaries
|
||||||
|
|
||||||
|
Place a static PHP binary for each target platform here. The Electron
|
||||||
|
app will use the bundled binary instead of requiring a system-wide PHP
|
||||||
|
installation.
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
Get static builds from https://dl.static-php.dev/static-php-cli/common/
|
||||||
|
(look for `php-8.3.*-cli-*` files). Required extensions: pdo_sqlite, zip, session.
|
||||||
|
|
||||||
|
linux/php — x86_64 Linux ELF binary
|
||||||
|
darwin/php — macOS aarch64 binary
|
||||||
|
win32/php.exe — Windows PE binary (not available from static-php-cli; use windows.php.net)
|
||||||
|
|
||||||
|
## Quick fetch
|
||||||
|
|
||||||
|
Use the included script from the `electron/` directory:
|
||||||
|
|
||||||
|
./fetch-php.sh
|
||||||
|
|
||||||
|
This auto-detects the latest PHP 8.3 version and downloads Linux + macOS binaries.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { contextBridge } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('hyprcrdElectron', {
|
||||||
|
platform: process.platform,
|
||||||
|
isElectron: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// Router for PHP's built-in server (replaces .htaccess rewrite rules).
|
||||||
|
// Static files are served directly; everything else goes to index.php.
|
||||||
|
|
||||||
|
// docRoot is set via `php -S ... -t <docRoot>` in main.js
|
||||||
|
$docRoot = $_SERVER['DOCUMENT_ROOT'];
|
||||||
|
|
||||||
|
// Electron passes HYPRCRD_DATA_DIR so the app stores all user data
|
||||||
|
// outside the app bundle (survives updates).
|
||||||
|
$dataDir = getenv('HYPRCRD_DATA_DIR');
|
||||||
|
if ($dataDir) {
|
||||||
|
define('DB_PATH', $dataDir . '/hyprcrd.db');
|
||||||
|
define('UPLOAD_DIR', $dataDir . '/uploads');
|
||||||
|
if (!is_dir($dataDir . '/uploads')) {
|
||||||
|
mkdir($dataDir . '/uploads', 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$file = $docRoot . $path;
|
||||||
|
if ($path !== '/' && is_file($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve uploaded files from the external data dir
|
||||||
|
if ($dataDir && str_starts_with($path, '/uploads/')) {
|
||||||
|
$extFile = $dataDir . $path;
|
||||||
|
if (is_file($extFile)) {
|
||||||
|
$mime = mime_content_type($extFile) ?: 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
readfile($extFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require $docRoot . '/index.php';
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
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(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+728
-58
@@ -1,5 +1,251 @@
|
|||||||
body { font-family: 'JetBrains Mono', monospace; }
|
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
||||||
.font-pixel { font-family: var(--t-font-pixel, 'Silkscreen', cursive) !important; }
|
|
||||||
|
/* 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; }
|
||||||
|
.brand { font-family: 'Silkscreen', cursive !important; }
|
||||||
|
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
||||||
|
|
||||||
|
/* ── User-customizable design tokens ── */
|
||||||
|
:root {
|
||||||
|
--ui-radius-card: 0px;
|
||||||
|
--ui-radius-button: 0px;
|
||||||
|
--ui-radius-input: 0px;
|
||||||
|
--ui-radius-modal: 4px;
|
||||||
|
--ui-radius-tag: 2px;
|
||||||
|
--ui-radius-image: 4px;
|
||||||
|
--ui-border-card: 1px;
|
||||||
|
--ui-border-style: solid;
|
||||||
|
--ui-bar-height: 3px;
|
||||||
|
--ui-pad-card: 16px;
|
||||||
|
--ui-gap-board: 0px;
|
||||||
|
--ui-shadow-card: none;
|
||||||
|
--ui-shadow-modal: 0 25px 50px -12px rgba(0,0,0,0.35);
|
||||||
|
--ui-shadow-header: 0 4px 20px -4px rgba(0,0,0,0.3);
|
||||||
|
--ui-shadow-footer: 0 -4px 20px -4px rgba(0,0,0,0.3);
|
||||||
|
--ui-lift-card: 0px;
|
||||||
|
--ui-blur-overlay: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-header { box-shadow: var(--ui-shadow-header); }
|
||||||
|
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
|
||||||
|
#footer-count { flex-shrink: 0; }
|
||||||
|
@media (min-width: 1450px) {
|
||||||
|
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
|
||||||
|
}
|
||||||
|
html { min-width: 1150px; }
|
||||||
|
|
||||||
|
/* Cards: geometry & shadow driven by design tokens */
|
||||||
|
.board-card {
|
||||||
|
--card-stack-shadow: 0 0 0 0 transparent;
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
border-width: var(--ui-border-card);
|
||||||
|
border-style: var(--ui-border-style);
|
||||||
|
box-shadow: var(--ui-shadow-card), var(--card-stack-shadow);
|
||||||
|
transition: border-color 0.12s, transform 0.15s, box-shadow 0.2s;
|
||||||
|
overflow: hidden; /* clip card-bar (and other inner elements) to the rounded corners */
|
||||||
|
}
|
||||||
|
.board-card.card-group {
|
||||||
|
--card-stack-shadow:
|
||||||
|
0 3px 0 -2px var(--t-border),
|
||||||
|
0 3px 0 0 var(--t-bg),
|
||||||
|
0 8px 0 -4px color-mix(in srgb, var(--t-border) 70%, transparent),
|
||||||
|
0 6px 0 0 var(--t-bg),
|
||||||
|
0 13px 0 -6px color-mix(in srgb, var(--t-border) 30%, transparent);
|
||||||
|
}
|
||||||
|
.board-card:hover { transform: translateY(calc(0px - var(--ui-lift-card))); }
|
||||||
|
.board-card > .p-4 { padding: var(--ui-pad-card); }
|
||||||
|
.board-card .card-tags { flex-shrink: 0; min-height: 18px; }
|
||||||
|
#board { gap: var(--ui-gap-board); }
|
||||||
|
|
||||||
|
/* Bordered action buttons */
|
||||||
|
button[class*="border-t-border"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||||
|
button[class*="border-t-accent"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||||
|
button[class*="border-t-danger"]:not(.toolbar-btn):not(.settings-tab):not(.bucket-tab):not(.ctx-item):not(.tp-btn),
|
||||||
|
a[class*="border-t-border"],
|
||||||
|
a[class*="border-t-accent"] {
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs / selects / textareas */
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"])[class*="border-t-border"],
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"])[class*="border-t-accent"],
|
||||||
|
textarea[class*="border-t-border"],
|
||||||
|
textarea[class*="border-t-accent"],
|
||||||
|
select[class*="border-t-border"],
|
||||||
|
select[class*="border-t-accent"] {
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag chips */
|
||||||
|
.tag-chip { border-radius: var(--ui-radius-tag); }
|
||||||
|
|
||||||
|
/* Prose images & card cover */
|
||||||
|
.prose img { border-radius: var(--ui-radius-image); }
|
||||||
|
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
||||||
|
|
||||||
|
/* Modal containers (the inner box of each overlay). Only for floating /
|
||||||
|
* framed modals — the fullscreen overlays (modal, detail, search, tasks,
|
||||||
|
* calendar, gallery, and the support / plugin overlays) fill the whole
|
||||||
|
* viewport now and don't want the rounded-corner + overflow:hidden
|
||||||
|
* combo, which would clip the top color bar and soften the edge-to-edge
|
||||||
|
* look. */
|
||||||
|
#settings-overlay > div,
|
||||||
|
#dialog > div {
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The help dialog scrolls its own body — keep the border-radius and
|
||||||
|
shadow but drop overflow:hidden so the Tailwind overflow-y-auto on
|
||||||
|
the same element actually takes effect. */
|
||||||
|
#help > div {
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
||||||
|
.overlay { backdrop-filter: blur(var(--ui-blur-overlay)); -webkit-backdrop-filter: blur(var(--ui-blur-overlay)); }
|
||||||
|
|
||||||
|
/* Animation speed (toggled via body class from applyDesign) */
|
||||||
|
body.anim-off *, body.anim-off *::before, body.anim-off *::after {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
body.anim-fast *, body.anim-fast *::before, body.anim-fast *::after {
|
||||||
|
transition-duration: 50ms !important;
|
||||||
|
animation-duration: 100ms !important;
|
||||||
|
}
|
||||||
|
body.anim-slow *, body.anim-slow *::before, body.anim-slow *::after {
|
||||||
|
transition-duration: 400ms !important;
|
||||||
|
animation-duration: 600ms !important;
|
||||||
|
}
|
||||||
|
/* Blink / pulse animations are status indicators with deliberate cadences
|
||||||
|
(cursor, overdue bar, unread chat button, new collab bucket 3-pulse).
|
||||||
|
Speeding them up makes them useless strobes, so they ignore the global
|
||||||
|
anim-* override via higher-specificity rules. */
|
||||||
|
body.anim-fast .cursor-blink::after,
|
||||||
|
body.anim-slow .cursor-blink::after {
|
||||||
|
animation: blink 1s step-end infinite !important;
|
||||||
|
animation-duration: 1s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .card-bar-pulse,
|
||||||
|
body.anim-slow .card-bar-pulse {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite !important;
|
||||||
|
animation-duration: 2s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger,
|
||||||
|
body.anim-slow .board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger {
|
||||||
|
animation-duration: 2s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .card-spinner,
|
||||||
|
body.anim-slow .card-spinner {
|
||||||
|
animation: spin 0.8s linear infinite !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme preview (settings) ── */
|
||||||
|
.theme-preview {
|
||||||
|
position: relative; padding: 14px; overflow: hidden;
|
||||||
|
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
||||||
|
}
|
||||||
|
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
||||||
|
.theme-preview .tp-card {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: var(--tp-border-card, 1px) var(--tp-border-style, solid) var(--t-border);
|
||||||
|
border-radius: var(--tp-radius-card, 0px);
|
||||||
|
box-shadow: var(--tp-shadow-card, none);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.theme-preview .tp-card-bar { height: var(--tp-bar-height, 3px); background: var(--t-accent); }
|
||||||
|
.theme-preview .tp-card-body { padding: var(--tp-pad-card, 14px); }
|
||||||
|
.theme-preview .tp-headline { font-family: var(--tp-font-headline); color: var(--t-bright); font-size: 16px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.theme-preview .tp-content { font-family: var(--tp-font-content); color: var(--t-text); font-size: 11px; line-height: 1.55; margin-bottom: 8px; }
|
||||||
|
.theme-preview .tp-meta { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.theme-preview .tp-tag {
|
||||||
|
font-family: var(--tp-font-ui); font-size: 9px;
|
||||||
|
color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--t-accent) 25%, transparent);
|
||||||
|
border-radius: var(--tp-radius-tag, 2px); padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.theme-preview .tp-ui { font-family: var(--tp-font-ui); font-size: 9px; color: var(--t-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.theme-preview .tp-side { display: flex; flex-direction: column; gap: 6px; min-width: 96px; }
|
||||||
|
.theme-preview .tp-btn {
|
||||||
|
font-family: var(--tp-font-ui); font-size: 10px;
|
||||||
|
color: var(--t-accent); border: 1px solid var(--t-accent);
|
||||||
|
padding: 4px 10px; border-radius: var(--tp-radius-button, 0px);
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-preview .tp-input {
|
||||||
|
font-family: var(--tp-font-ui); font-size: 10px;
|
||||||
|
color: var(--t-text); background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
padding: 4px 8px; border-radius: var(--tp-radius-input, 0px);
|
||||||
|
outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range slider styling for design panel */
|
||||||
|
.design-slider {
|
||||||
|
flex: 1; height: 4px;
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
background: var(--t-border); border-radius: 2px; outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.design-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
.design-slider::-moz-range-thumb {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||||||
|
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
|
||||||
|
.design-row > select { width: 110px; }
|
||||||
|
.design-row > .design-val { font-size: 11px; color: var(--t-muted); width: 44px; text-align: right; flex-shrink: 0; }
|
||||||
|
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
|
||||||
|
.design-section-label:first-child { margin-top: 0; }
|
||||||
|
#design-advanced > summary { list-style: none; }
|
||||||
|
#design-advanced > summary::-webkit-details-marker { display: none; }
|
||||||
|
#design-advanced > summary:hover { color: var(--t-bright); }
|
||||||
|
#design-advanced .design-advanced-caret { transition: transform 0.15s; display: inline-block; }
|
||||||
|
#design-advanced[open] .design-advanced-caret { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* Settings peek: hide only the dim background so the user can see the real UI behind, while the settings popup stays open and interactive */
|
||||||
|
body.settings-peeking #settings-overlay {
|
||||||
|
background: transparent !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
||||||
|
|
||||||
/* Toolbar toggles */
|
/* Toolbar toggles */
|
||||||
.toolbar-toggle {
|
.toolbar-toggle {
|
||||||
@@ -55,16 +301,6 @@ body { font-family: 'JetBrains Mono', monospace; }
|
|||||||
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
||||||
*::-webkit-scrollbar-corner { background: transparent; }
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
/* Scanlines */
|
|
||||||
body::after {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blinking cursor */
|
/* Blinking cursor */
|
||||||
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
@@ -76,13 +312,61 @@ body::after {
|
|||||||
.line-clamp-5 { -webkit-line-clamp: 5; }
|
.line-clamp-5 { -webkit-line-clamp: 5; }
|
||||||
|
|
||||||
/* Superview */
|
/* Superview */
|
||||||
.superview-grid { column-gap: 12px; padding: 1rem 1.5rem 6rem; }
|
/* Single-column list view (toggled via #superview-toggle). Each row is
|
||||||
.superview-item { padding: 3px 0; display: flex; align-items: center; gap: 6px; cursor: pointer; break-inside: avoid; transition: color 0.1s; }
|
a flex line with: color dot · optional icon · title (flex-grows) ·
|
||||||
.superview-item:hover { color: var(--t-bright); }
|
tag chips · bucket · date. Title ellipsizes when narrow. */
|
||||||
.superview-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
.superview-grid { padding: 1rem 1.5rem 6rem; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 1px; }
|
||||||
.superview-title { font-size: 11px; color: var(--t-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.superview-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--t-border) 50%, transparent);
|
||||||
|
}
|
||||||
|
.superview-item:hover { background: color-mix(in srgb, var(--t-raised) 60%, transparent); color: var(--t-bright); }
|
||||||
|
.superview-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.superview-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
.superview-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--t-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.superview-item:hover .superview-title { color: var(--t-bright); }
|
.superview-item:hover .superview-title { color: var(--t-bright); }
|
||||||
.superview-icon { font-size: 10px; flex-shrink: 0; }
|
.superview-tags { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
.superview-tag {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--ui-radius-tag, 2px);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.superview-tag-more { color: var(--t-dim); background: transparent; }
|
||||||
|
.superview-bucket {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.superview-bucket i { font-size: 12px; }
|
||||||
|
.superview-date {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.board-card.preview-active { border-color: var(--t-accent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 12px -2px color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
.board-card.preview-active { border-color: var(--t-accent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 12px -2px color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||||
|
|
||||||
@@ -108,17 +392,32 @@ body::after {
|
|||||||
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
||||||
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
||||||
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
||||||
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
||||||
|
/* Rounded-cards variant: the corner triangle gets clipped by the
|
||||||
|
* rounded border, so toggle to a circle pill that sits comfortably
|
||||||
|
* inside any radius. JS adds `rounded-cards` on body whenever
|
||||||
|
* --ui-radius-card is > 0. */
|
||||||
|
body.rounded-cards .card-done-badge {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
bottom: 6px; right: 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
body.rounded-cards .card-done-badge i { position: static; bottom: auto; right: auto; font-size: 11px; color: #000; font-weight: bold; }
|
||||||
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
||||||
|
.board-card > .card-bar { border-top-left-radius: var(--ui-radius-card); border-top-right-radius: var(--ui-radius-card); }
|
||||||
.board-card { min-height: var(--card-min-h, 180px); }
|
.board-card { min-height: var(--card-min-h, 180px); }
|
||||||
.card-layer { position: relative; z-index: 1; }
|
.card-layer { position: relative; z-index: 1; }
|
||||||
.card-has-cover {
|
.card-has-cover {
|
||||||
@@ -144,27 +443,13 @@ body::after {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Group stacking effect */
|
/* Group stacking effect — implemented via box-shadow on .board-card.card-group above
|
||||||
.card-group {
|
(pseudo-elements would be clipped by the card's overflow:hidden). */
|
||||||
position: relative;
|
.card-group { position: relative; }
|
||||||
}
|
|
||||||
.card-group::before,
|
|
||||||
.card-group::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 4px; right: 4px;
|
|
||||||
height: 100%;
|
|
||||||
border: 1px solid var(--t-border);
|
|
||||||
background: var(--t-surface);
|
|
||||||
border-radius: inherit;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
.card-group::before { top: 4px; opacity: 0.6; }
|
|
||||||
.card-group::after { top: 8px; opacity: 0.3; left: 8px; right: 8px; }
|
|
||||||
.card-group-badge {
|
.card-group-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: 6px;
|
||||||
right: -6px;
|
right: 6px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
@@ -212,8 +497,13 @@ body::after {
|
|||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||||
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
||||||
|
.card-focused {
|
||||||
|
outline: 2px solid var(--t-accent) !important;
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: 0 0 0 1px var(--t-accent), 0 0 16px -4px color-mix(in srgb, var(--t-accent) 40%, transparent) !important;
|
||||||
|
}
|
||||||
.card-select-check {
|
.card-select-check {
|
||||||
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
||||||
border-radius: 50%; background: var(--t-accent); z-index: 6;
|
border-radius: 50%; background: var(--t-accent); z-index: 6;
|
||||||
@@ -226,26 +516,68 @@ body::after {
|
|||||||
transform: rotate(45deg); margin-top: -2px;
|
transform: rotate(45deg); margin-top: -2px;
|
||||||
}
|
}
|
||||||
.card-dragging { opacity: 0.15; transform: scale(0.95); }
|
.card-dragging { opacity: 0.15; transform: scale(0.95); }
|
||||||
|
@keyframes card-remove {
|
||||||
|
0% { opacity:1; transform:scale(1); }
|
||||||
|
100% { opacity:0; transform:scale(0.85) translateY(8px); }
|
||||||
|
}
|
||||||
|
.card-removing {
|
||||||
|
animation: card-remove 0.25s ease-in forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Move-in-flight: card ghosted with a spinner overlay while the
|
||||||
|
* PATCH + reloadAll round-trip completes. The pseudo-elements paint
|
||||||
|
* a dim scrim and a centred spinning ring so the origin bucket
|
||||||
|
* clearly shows the card is "leaving". Removed by the next
|
||||||
|
* renderBoard() since the DOM is rebuilt from scratch. */
|
||||||
|
.board-card.card-moving {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
margin: -11px 0 0 -11px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: var(--t-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.box-select {
|
.box-select {
|
||||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||||
pointer-events: none; z-index: 10;
|
pointer-events: none; z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop indicators — thick glowing bar */
|
/* Card drop cursor — single absolutely-positioned vertical line that
|
||||||
.card-drop-before::before,
|
lives inside #board (which is `position: relative`) and gets
|
||||||
.card-drop-after::after {
|
repositioned by the dragover handlers in `board.js`. Replaces the
|
||||||
content: '';
|
old `.card-drop-before::before` / `.card-drop-after::after` pseudo-
|
||||||
|
elements so the indicator is a real DOM node in the gap between cards
|
||||||
|
instead of a sliver glued to one card. The "before first card"
|
||||||
|
case works automatically: the first card's left-25% zone positions
|
||||||
|
the cursor at `card.offsetLeft - 6`, which sits in #board's left
|
||||||
|
padding and stays visible. */
|
||||||
|
#card-drop-cursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 4px;
|
width: 4px;
|
||||||
background: var(--t-accent);
|
background: var(--t-accent);
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
box-shadow: 0 0 12px var(--t-accent), 0 0 4px var(--t-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.card-drop-before::before { left: -6px; }
|
|
||||||
.card-drop-after::after { right: -6px; }
|
|
||||||
|
|
||||||
/* Group fan preview */
|
/* Group fan preview */
|
||||||
.group-fan {
|
.group-fan {
|
||||||
@@ -470,8 +802,211 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.bucket-add.drag-over i,
|
.bucket-add.drag-over i,
|
||||||
.bucket-add.drag-over span { color: inherit !important; }
|
.bucket-add.drag-over span { color: inherit !important; }
|
||||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
/* Bucket reorder drop cursor — a single absolutely-positioned vertical
|
||||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
line that lives inside #bucket-bar and gets repositioned by the
|
||||||
|
dragover handler in `public/js/modules/buckets.js`. Showing the cursor
|
||||||
|
*between* tiles makes the insert point unambiguous next to the
|
||||||
|
super-bucket nest gesture (center 50% → nest, edges 25% → reorder). */
|
||||||
|
#bucket-drop-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--t-accent);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Drop-into-center highlight: target tile gets an outline that says
|
||||||
|
"release here to nest". Distinct from the insertion cursor so the
|
||||||
|
user can tell reorder vs. nest at a glance. */
|
||||||
|
.bucket-tab.bucket-nest-target {
|
||||||
|
outline: 2px dashed var(--t-accent) !important;
|
||||||
|
outline-offset: 1px;
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bucket bar: no visible scrollbar, soft edge fades when the row
|
||||||
|
overflows so the user knows there's more off-screen. Fade is
|
||||||
|
implemented via absolutely-positioned overlay pseudo-elements on
|
||||||
|
#bucket-row (parent) rather than mask-image on the bar itself,
|
||||||
|
because mask-image would also clip super-bucket popovers rendered
|
||||||
|
inside the bar. Overlays use pointer-events:none so clicks pass
|
||||||
|
through to the tabs underneath. */
|
||||||
|
#bucket-bar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
#bucket-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
#bucket-row::before,
|
||||||
|
#bucket-row::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
#bucket-row::before {
|
||||||
|
left: 24px; /* matches .px-6 = 24px on #bucket-row */
|
||||||
|
background: linear-gradient(to right, var(--t-bg) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
#bucket-row::after {
|
||||||
|
/* Stop at the right edge of #bucket-bar (before the toolbar
|
||||||
|
toggles). The toolbar is flex-0, bar is flex-1 — the easiest
|
||||||
|
way is to inset the overlay by the total width of the toolbar
|
||||||
|
via a right offset that the JS writes. Defaults to 24px (the
|
||||||
|
.px-6 pad) and the JS overrides with the real value. */
|
||||||
|
right: var(--bucket-row-right-inset, 24px);
|
||||||
|
background: linear-gradient(to left, var(--t-bg) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
#bucket-row.fade-left::before,
|
||||||
|
#bucket-row.fade-both::before { opacity: 1; }
|
||||||
|
#bucket-row.fade-right::after,
|
||||||
|
#bucket-row.fade-both::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
|
||||||
|
arrive while the user is viewing a different bucket. Cleared the
|
||||||
|
moment the user navigates back to all (renderBoard drains the
|
||||||
|
unseen set + pulses the cards). The dot sits *inside* the tab's
|
||||||
|
top-right corner because the parent #main-header has overflow:
|
||||||
|
hidden — anything positioned outside the tab gets clipped twice
|
||||||
|
(overflow-x-auto on bucket-bar + overflow:hidden on header). */
|
||||||
|
.bucket-tab.has-unread { position: relative; }
|
||||||
|
.bar-unread-dot {
|
||||||
|
position: absolute; top: 3px; right: 3px;
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 75%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Look at me" pulse on cards that just arrived. Triggered by
|
||||||
|
board.js when the all view is shown with unseen cards in the set —
|
||||||
|
either because new cards arrived while the user was already on
|
||||||
|
all, or because the user just navigated to all and the unseen ids
|
||||||
|
accumulated since the last visit. 3 short accent flashes, then
|
||||||
|
the class is stripped automatically by setTimeout. */
|
||||||
|
@keyframes card-pulse-new {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: var(--ui-shadow-card, none),
|
||||||
|
0 0 0 0 color-mix(in srgb, var(--t-accent) 80%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: var(--ui-shadow-card, none),
|
||||||
|
0 0 18px 4px color-mix(in srgb, var(--t-accent) 75%, transparent),
|
||||||
|
inset 0 0 0 2px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.board-card.card-pulse-new {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 3;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
/* Single pulse for cards the user just created themselves — subtler
|
||||||
|
than the triple-pulse for relay-synced cards so the user registers
|
||||||
|
"I made this" without the urgency of "someone else sent this". */
|
||||||
|
.board-card.card-pulse-local {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 1;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
|
||||||
|
body.anim-fast .board-card.card-pulse-new,
|
||||||
|
body.anim-slow .board-card.card-pulse-new {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 3 !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
body.anim-fast .board-card.card-pulse-local,
|
||||||
|
body.anim-slow .board-card.card-pulse-local {
|
||||||
|
animation: card-pulse-new 0.8s ease-in-out 1 !important;
|
||||||
|
animation-duration: 0.8s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||||
|
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||||
|
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
||||||
|
.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 {
|
||||||
|
z-index: 220;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal, 0 10px 30px rgba(0,0,0,0.4));
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shortcut help table — table-fixed prevents column wobble when content
|
||||||
|
length varies between sections. kbd styling matches typical keyboard-cap
|
||||||
|
look (surface bg, border, mono font, tiny padding). */
|
||||||
|
/* Asset tab filters in the gallery/assets overlay header */
|
||||||
|
.gallery-tab { border-radius: var(--ui-radius-button, 0); }
|
||||||
|
.gallery-tab:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.gallery-tab.active { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.shortcut-table { width:100%; table-layout:fixed; font-size:12px; border-collapse:collapse; }
|
||||||
|
.shortcut-table td { padding:4px 0; vertical-align:middle; color:var(--t-text); }
|
||||||
|
.shortcut-table td:first-child { width:48%; color:var(--t-dim); white-space:nowrap; font-size:11px; }
|
||||||
|
/* Keycap look: 3D-ish raised surface with bottom shadow simulating
|
||||||
|
depth, subtle gradient, rounded corners. Min-width so single-letter
|
||||||
|
keys don't look squished. The "+" separator between combo keys sits
|
||||||
|
outside <kbd> and inherits the dim color from the td. */
|
||||||
|
.shortcut-table kbd {
|
||||||
|
display:inline-block;
|
||||||
|
min-width:20px; text-align:center;
|
||||||
|
padding:3px 7px;
|
||||||
|
background: linear-gradient(180deg, color-mix(in srgb, var(--t-raised) 90%, white 10%), var(--t-bg));
|
||||||
|
border:1px solid var(--t-border);
|
||||||
|
border-bottom-width:2px;
|
||||||
|
border-radius:4px;
|
||||||
|
font-family:var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size:10px;
|
||||||
|
color:var(--t-bright);
|
||||||
|
line-height:1.4;
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, var(--t-border) 80%, black 20%),
|
||||||
|
inset 0 1px 0 color-mix(in srgb, var(--t-raised) 60%, white 15%);
|
||||||
|
}
|
||||||
|
.shortcut-table .mouse {
|
||||||
|
font-family:var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size:9px; color:var(--t-dim);
|
||||||
|
}
|
||||||
|
/* The auto-generated shortcut help uses a 3-column grid on the
|
||||||
|
.shortcut-help-content div. Collapse to 2 then 1 on smaller screens. */
|
||||||
|
.shortcut-help-content { column-gap: 20px; }
|
||||||
|
.shortcut-help-content section { break-inside: avoid; margin-bottom: 16px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shortcut-help-content { grid-template-columns: 1fr 1fr !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.shortcut-help-content { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag chips */
|
/* Tag chips */
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
@@ -489,13 +1024,14 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: var(--ui-radius-button); transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
||||||
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
||||||
|
|
||||||
/* Editor */
|
/* Editor */
|
||||||
#editor-body { tab-size: 4; resize: none; }
|
#editor-body { tab-size: 4; resize: none; }
|
||||||
#editor-body:focus { outline: none; }
|
#editor-body:focus { outline: none; }
|
||||||
#editor-gutter div { padding-right: 2px; box-sizing: border-box; }
|
#editor-gutter::-webkit-scrollbar { display: none; }
|
||||||
|
#editor-gutter > div { padding-right: 2px; box-sizing: border-box; }
|
||||||
.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); }
|
.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); }
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
@@ -505,6 +1041,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
.search-hit { scroll-margin: 8px; }
|
.search-hit { scroll-margin: 8px; }
|
||||||
.search-hit.active { background: var(--t-raised); }
|
.search-hit.active { background: var(--t-raised); }
|
||||||
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
.search-hit mark, #search-preview-content mark { background: none; color: var(--t-accent); font-weight: 700; }
|
||||||
|
#search-replace-toggle.active { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
#ctx-menu .ctx-item, #bucket-ctx .ctx-item {
|
||||||
@@ -540,7 +1077,7 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
|
|
||||||
/* Prose (markdown) */
|
/* Prose (markdown) */
|
||||||
.prose { line-height: 1.75; }
|
.prose { line-height: 1.75; }
|
||||||
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-pixel, 'Silkscreen', cursive); margin: 1.5em 0 0.6em; font-weight: 700; }
|
.prose h1, .prose h2, .prose h3, .prose h4 { color: var(--t-bright); font-family: var(--t-font-headline, var(--t-font-pixel, 'Silkscreen', cursive)); margin: 1.5em 0 0.6em; font-weight: 700; }
|
||||||
.prose h1 { font-size: 1.8em; padding-bottom: 0.3em; border-bottom: 1px solid var(--t-border); }
|
.prose h1 { font-size: 1.8em; padding-bottom: 0.3em; border-bottom: 1px solid var(--t-border); }
|
||||||
.prose h2 { font-size: 1.4em; }
|
.prose h2 { font-size: 1.4em; }
|
||||||
.prose h3 { font-size: 1.15em; }
|
.prose h3 { font-size: 1.15em; }
|
||||||
@@ -622,17 +1159,18 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
margin-left: -1.5em !important;
|
margin-left: -1.5em !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
min-height: 28px;
|
min-height: 24px;
|
||||||
padding: 5px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
.todo-item p { margin: 0 !important; min-height: 0 !important; }
|
||||||
|
.todo-cb { margin-top: 2px; }
|
||||||
.todo-item { cursor: pointer; }
|
.todo-item { cursor: pointer; }
|
||||||
.todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); }
|
.todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); }
|
||||||
.todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; }
|
.todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; }
|
||||||
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
|
||||||
.todo-cb {
|
.todo-cb {
|
||||||
width: 18px; height: 18px; min-width: 18px;
|
width: 18px; height: 18px; min-width: 18px;
|
||||||
transform: translateY(-9px);
|
|
||||||
border: 2px solid var(--t-border); border-radius: 3px;
|
border: 2px solid var(--t-border); border-radius: 3px;
|
||||||
background: transparent; cursor: pointer;
|
background: transparent; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
@@ -659,12 +1197,39 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.prose img {
|
.prose img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--t-border);
|
border: 1px solid var(--t-border);
|
||||||
margin: 0.8em 0;
|
margin: 0.8em 0;
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
.prose img.resizing { outline: 2px solid var(--t-accent); }
|
.prose img.resizing { outline: 2px solid var(--t-accent); }
|
||||||
|
|
||||||
|
/* Preview panes: ensure the prose content never overflows horizontally so
|
||||||
|
images can resolve their `max-width: 100%` against a constrained container.
|
||||||
|
Without this, images with explicit width attrs (e.g. <img width="2000">)
|
||||||
|
push the content box wider than the flex pane. */
|
||||||
|
#editor-preview,
|
||||||
|
#board-preview-body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#preview-content,
|
||||||
|
#board-preview-body > * {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#editor-preview .prose img,
|
||||||
|
#board-preview-body.prose img,
|
||||||
|
#board-preview-body .prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
#editor-preview .prose img:not([width]),
|
||||||
|
#board-preview-body.prose img:not([width]),
|
||||||
|
#board-preview-body .prose img:not([width]) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||||
@@ -723,3 +1288,108 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
||||||
border-color: var(--t-accent);
|
border-color: var(--t-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remote-edit banner in the detail view.
|
||||||
|
Shown when a foreign sync replaced the card we're currently reading — the
|
||||||
|
user gets a manual Reload button instead of having the content change
|
||||||
|
under them mid-read. */
|
||||||
|
.detail-stale-banner {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: color-mix(in srgb, var(--t-accent2) 10%, transparent);
|
||||||
|
border-bottom: 1px solid var(--t-accent2);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
}
|
||||||
|
.detail-stale-banner i.ph-info { color: var(--t-accent2); flex-shrink: 0; font-size: 14px; }
|
||||||
|
.detail-stale-banner span { flex: 1; }
|
||||||
|
.detail-stale-banner button {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-accent2);
|
||||||
|
color: var(--t-accent2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
|
||||||
|
|
||||||
|
/* Sync + autosync combo: the pair reads as one button.
|
||||||
|
- At rest: Sync has no right border, autosync has no left border —
|
||||||
|
only the outer rectangle remains.
|
||||||
|
- Hover Sync: the right border comes back in accent so Sync gets a
|
||||||
|
complete outline around itself.
|
||||||
|
- Hover autosync: no accent border at all — instead a background
|
||||||
|
fill so the pair doesn't end up with a mismatched half-outline. */
|
||||||
|
.header-sync-group { align-items: stretch; }
|
||||||
|
.header-sync-group > button:first-child {
|
||||||
|
border-right: 0 !important;
|
||||||
|
}
|
||||||
|
.header-sync-group > button:first-child:hover {
|
||||||
|
border-right: 1px solid var(--t-accent) !important;
|
||||||
|
}
|
||||||
|
.header-sync-group > button + button {
|
||||||
|
border-left: 0 !important;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
#header-autosync:hover {
|
||||||
|
background: var(--t-accent);
|
||||||
|
border-color: var(--t-accent) !important;
|
||||||
|
color: var(--t-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autosync toggle button.
|
||||||
|
Border stays neutral in both states — the dot carries the status
|
||||||
|
signal (accent + pulse when on, grey when off). Hover styling is
|
||||||
|
handled by the bg-fill rule further down. */
|
||||||
|
#header-autosync {
|
||||||
|
border-color: var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-autosync-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 9px; height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-accent);
|
||||||
|
transition: background 0.15s, transform 0.2s;
|
||||||
|
}
|
||||||
|
#header-autosync[data-autosync="off"] .header-autosync-dot {
|
||||||
|
background: var(--t-dim);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* Hover: button fills with solid accent (matching the Sync button's
|
||||||
|
hover border color). Dot switches to the app background color so it
|
||||||
|
reads as a knockout against the fill. Pulsing keeps tracking real
|
||||||
|
status — when a sync fires during hover the ring still animates. */
|
||||||
|
#header-autosync:hover .header-autosync-dot {
|
||||||
|
background: var(--t-bg);
|
||||||
|
}
|
||||||
|
/* Pulse shadow is accent-colored too, which would be invisible against
|
||||||
|
the accent fill. Swap it to a bg-toned ring during hover so the
|
||||||
|
pulse stays visible. */
|
||||||
|
#header-autosync:hover .header-autosync-dot.pulsing {
|
||||||
|
animation: header-autosync-pulse-on-hover 0.9s ease-out 1;
|
||||||
|
}
|
||||||
|
@keyframes header-autosync-pulse-on-hover {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-bg) 75%, transparent); transform: scale(1); }
|
||||||
|
40% { transform: scale(1.4); }
|
||||||
|
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-bg) 0%, transparent); transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Burst pulse on each sync. JS adds `.pulsing` at the start of a sync
|
||||||
|
and removes it after the animation finishes. Tuned to be visible
|
||||||
|
against the button background — a generous ring that fades out. */
|
||||||
|
.header-autosync-dot.pulsing {
|
||||||
|
animation: header-autosync-pulse 0.9s ease-out 1;
|
||||||
|
}
|
||||||
|
@keyframes header-autosync-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-accent) 75%, transparent); transform: scale(1); }
|
||||||
|
40% { transform: scale(1.4); }
|
||||||
|
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-accent) 0%, transparent); transform: scale(1); }
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolderLine truncated
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
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(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+1214
-353
File diff suppressed because it is too large.
Load diff
+413
-89
@@ -6,17 +6,21 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
|
|||||||
import { state, getGroupCards } from './modules/state.js';
|
import { state, getGroupCards } from './modules/state.js';
|
||||||
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
||||||
import './modules/theme.js';
|
import './modules/theme.js';
|
||||||
import { loadBuckets, renderTagBar } from './modules/buckets.js';
|
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
||||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
||||||
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
|
import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './modules/editor.js';
|
||||||
import { openSearch, closeSearch } from './modules/search.js';
|
import { 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 { openGallery, closeGallery } from './modules/gallery.js';
|
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
|
import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
|
||||||
|
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
|
||||||
|
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||||
|
|
||||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ window._closeTasks = closeTasks;
|
|||||||
window._renderBoard = renderBoard;
|
window._renderBoard = renderBoard;
|
||||||
window._openCalendar = openCalendar;
|
window._openCalendar = openCalendar;
|
||||||
window._triggerSync = triggerSync;
|
window._triggerSync = triggerSync;
|
||||||
|
window._openPluginSettings = openPluginSettings;
|
||||||
window._loadCaldavSettings = loadCaldavSettings;
|
window._loadCaldavSettings = loadCaldavSettings;
|
||||||
window._loadRelaySettings = loadRelaySettings;
|
window._loadRelaySettings = loadRelaySettings;
|
||||||
window._renderTagBar = renderTagBar;
|
window._renderTagBar = renderTagBar;
|
||||||
@@ -36,7 +41,21 @@ window._ungroupCard = ungroupCard;
|
|||||||
window._getSelectedIds = getSelectedIds;
|
window._getSelectedIds = getSelectedIds;
|
||||||
window._clearSelection = clearSelection;
|
window._clearSelection = clearSelection;
|
||||||
window._refreshTasks = refreshTasksView;
|
window._refreshTasks = refreshTasksView;
|
||||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
window.openFindBar = openFindBar;
|
||||||
|
window._reloadAll = async () => {
|
||||||
|
await loadBuckets();
|
||||||
|
state.cards = await api('api/cards') || [];
|
||||||
|
state.cardsLoaded = true;
|
||||||
|
renderBoard();
|
||||||
|
refreshCalendar();
|
||||||
|
// Let plugins / modules react to a full card reload (detail view checks
|
||||||
|
// whether the currently viewed card is now stale; collab-buckets uses it
|
||||||
|
// to refresh its decorators).
|
||||||
|
window.hyprcrd?.hooks?.emit?.('cards.reloaded');
|
||||||
|
};
|
||||||
|
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
||||||
|
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||||
|
window.state = state;
|
||||||
window._extractToCard = async (text) => {
|
window._extractToCard = async (text) => {
|
||||||
if (!text?.trim()) return;
|
if (!text?.trim()) return;
|
||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
@@ -68,10 +87,14 @@ function setCachedCards(cards) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCards() {
|
async function loadCards() {
|
||||||
// Phase 0: Show cached cards instantly (from previous session)
|
// Phase 0: Show cached cards instantly (from previous session).
|
||||||
|
// A hit here is enough to flip cardsLoaded — fresh meta will
|
||||||
|
// overwrite state.cards a moment later, but the spinner→content
|
||||||
|
// transition already happened.
|
||||||
const cached = getCachedCards();
|
const cached = getCachedCards();
|
||||||
if (cached.length) {
|
if (cached.length) {
|
||||||
state.cards = cached.map(c => ({ ...c, body: null }));
|
state.cards = cached.map(c => ({ ...c, body: null }));
|
||||||
|
state.cardsLoaded = true;
|
||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
@@ -79,16 +102,20 @@ async function loadCards() {
|
|||||||
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
||||||
const meta = await api('api/cards/meta');
|
const meta = await api('api/cards/meta');
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
state.cardsLoaded = true;
|
||||||
state.cards = meta.map(c => ({ ...c, body: null }));
|
state.cards = meta.map(c => ({ ...c, body: null }));
|
||||||
setCachedCards(meta);
|
setCachedCards(meta);
|
||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
// Phase 2: Load full cards in batches → replace spinners with content
|
// Phase 2: Load full card bodies. One fetch up to the ID cap — with
|
||||||
const batchSize = 25;
|
// the /api/cards(?ids=) endpoint now indexed on cards.position +
|
||||||
for (let i = 0; i < meta.length; i += batchSize) {
|
// created_at a single big batch is faster than many small ones, and
|
||||||
const batch = meta.slice(i, i + batchSize);
|
// shorter perceived spinner time matters more than memory. Fall back
|
||||||
const ids = batch.map(c => c.id).join(',');
|
// to smaller batches only if the URL would blow past ~2000 chars.
|
||||||
|
const IDS_MAX_PER_FETCH = 500;
|
||||||
|
for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) {
|
||||||
|
const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(',');
|
||||||
const full = await api('api/cards?ids=' + ids);
|
const full = await api('api/cards?ids=' + ids);
|
||||||
if (!full) continue;
|
if (!full) continue;
|
||||||
for (const card of full) {
|
for (const card of full) {
|
||||||
@@ -107,10 +134,13 @@ function closeHelp() { helpOverlay?.classList.remove('open'); }
|
|||||||
document.getElementById('help-btn')?.addEventListener('click', openHelp);
|
document.getElementById('help-btn')?.addEventListener('click', openHelp);
|
||||||
document.getElementById('help-close')?.addEventListener('click', closeHelp);
|
document.getElementById('help-close')?.addEventListener('click', closeHelp);
|
||||||
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
|
helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); });
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const shortcutsOverlay = document.getElementById('shortcuts');
|
||||||
|
function openShortcuts() { shortcutsOverlay?.classList.add('open'); }
|
||||||
|
function closeShortcuts() { shortcutsOverlay?.classList.remove('open'); }
|
||||||
|
document.getElementById('shortcuts-btn')?.addEventListener('click', openShortcuts);
|
||||||
|
document.getElementById('shortcuts-close')?.addEventListener('click', closeShortcuts);
|
||||||
|
shortcutsOverlay?.addEventListener('click', e => { if (e.target === shortcutsOverlay) closeShortcuts(); });
|
||||||
// ── Export / Import ──
|
// ── Export / Import ──
|
||||||
|
|
||||||
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
|
||||||
@@ -124,71 +154,85 @@ importFile?.addEventListener('change', async e => {
|
|||||||
|
|
||||||
// Check if it's a selective export (merge) or ask for full replace
|
// Check if it's a selective export (merge) or ask for full replace
|
||||||
const form = new FormData(); form.append('file', file);
|
const form = new FormData(); form.append('file', file);
|
||||||
const mode = await showDialog({ title: 'import_data', message: 'Wie importieren?', okLabel: 'merge (hinzufuegen)', cancelLabel: 'replace (alles ersetzen)' });
|
const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'Merge (add to existing)', cancelLabel: 'Replace (wipe all)' });
|
||||||
// mode: true = merge, null = replace
|
// mode: true = merge, null = replace
|
||||||
if (mode === null) {
|
if (mode === null) {
|
||||||
const sure = await showDialog({ title: 'replace_all', message: 'Alle bestehenden Daten werden ersetzt!', danger: true, okLabel: 'replace' });
|
const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'Replace' });
|
||||||
if (!sure) { e.target.value = ''; return; }
|
if (!sure) { e.target.value = ''; return; }
|
||||||
}
|
}
|
||||||
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'); state.cardsLoaded = true; renderBoard();
|
||||||
} catch {}
|
} catch (err) {
|
||||||
e.target.value = '';
|
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
||||||
});
|
}
|
||||||
|
|
||||||
const importLogseq = document.getElementById('import-logseq-file');
|
|
||||||
document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click());
|
|
||||||
importLogseq?.addEventListener('change', async e => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const form = new FormData(); form.append('file', file);
|
|
||||||
try {
|
|
||||||
const res = await fetch('api/import-logseq', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
|
|
||||||
const data = JSON.parse(await res.text());
|
|
||||||
if (data.error) throw new Error(data.error);
|
|
||||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
|
||||||
await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' });
|
|
||||||
} catch {}
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Auth ──
|
// ── Auth ──
|
||||||
|
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true });
|
const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'Logout', danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
await fetch('api/auth/logout', { method: 'POST' });
|
await fetch('api/auth/logout', { method: 'POST' });
|
||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function checkAuth() {
|
// Re-usable login prompt loop. Shared between the boot-time auth check
|
||||||
const res = await fetch('api/auth/status');
|
// and the 401-driven re-auth path (session expiry while the UI is open).
|
||||||
const data = await res.json();
|
// Concurrent callers collapse onto a single dialog via reauthPromise so a
|
||||||
if (data.csrf) setCsrf(data.csrf);
|
// wave of background polls doesn't stack up N prompts.
|
||||||
|
let reauthPromise = null;
|
||||||
if (data.setup) {
|
async function promptLogin(message = '') {
|
||||||
let pw = null;
|
if (reauthPromise) return reauthPromise;
|
||||||
while (!pw || pw.length < 4) {
|
reauthPromise = (async () => {
|
||||||
pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '' });
|
while (true) {
|
||||||
if (pw && pw.length < 4) pw = null;
|
const pw = await showDialog({ title: 'login', message, input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
||||||
}
|
|
||||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.error) { location.reload(); return; }
|
|
||||||
if (d.csrf) setCsrf(d.csrf);
|
|
||||||
} else if (!data.logged_in) {
|
|
||||||
let ok = false;
|
|
||||||
while (!ok) {
|
|
||||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '' });
|
|
||||||
if (!pw) continue;
|
if (!pw) continue;
|
||||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); }
|
if (d.ok) {
|
||||||
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// wrong password — loop and re-prompt
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
try { await reauthPromise; } finally { reauthPromise = null; }
|
||||||
|
}
|
||||||
|
// Expose for the api() helpers so a 401 from any fetch triggers the same
|
||||||
|
// dialog + CSRF refresh flow.
|
||||||
|
window._reauth = () => promptLogin('Session expired — sign in to continue.');
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
const res = await fetch('api/auth/status');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); }
|
||||||
|
// Refresh version-displaying spans from the live API response so the
|
||||||
|
// GUI doesn't show a stale APP_VERSION when the PHP page is cached.
|
||||||
|
if (data.version) {
|
||||||
|
for (const el of document.querySelectorAll('[data-app-version]')) {
|
||||||
|
el.textContent = 'v' + data.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.setup) {
|
||||||
|
let pw = null;
|
||||||
|
while (!pw || pw.length < 10) {
|
||||||
|
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||||
|
if (pw && pw.length < 10) pw = null;
|
||||||
|
}
|
||||||
|
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { location.reload(); return; }
|
||||||
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
|
} else if (!data.logged_in) {
|
||||||
|
await promptLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBuckets();
|
loadBuckets();
|
||||||
@@ -196,50 +240,330 @@ async function checkAuth() {
|
|||||||
startBackgroundSync();
|
startBackgroundSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global keyboard ──
|
// ── Keyboard shortcuts (central registry) ──
|
||||||
|
//
|
||||||
|
// All document-level shortcuts are registered here via registerShortcut().
|
||||||
|
// The dispatcher in shortcuts.js handles key matching, guards, and
|
||||||
|
// priority ordering. Element-specific shortcuts (editor textarea, search
|
||||||
|
// input, canvas, dialog) stay on their respective DOM elements.
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
const { board: gBoard } = guards;
|
||||||
const modal = document.getElementById('modal');
|
|
||||||
const detail = document.getElementById('detail');
|
|
||||||
const search = document.getElementById('search');
|
|
||||||
const tasks = document.getElementById('tasks');
|
|
||||||
if (isDialogOpen()) return;
|
|
||||||
|
|
||||||
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
|
// — Escape chain (highest priority first) —
|
||||||
|
registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { if (document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
|
||||||
|
registerShortcut({ key: 'z', guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'detail', keys: '<kbd>Z</kbd>', label: 'toggle zen mode', handler: () => document.getElementById('detail-zen')?.click() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 40, guard: () => !isDialogOpen() && guards.isOpen('calendar'), section: 'global', label: 'close calendar', hidden: true, handler: () => closeCalendar() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 30, guard: () => !isDialogOpen() && guards.isOpen('shortcuts'), section: 'global', label: 'close shortcuts', hidden: true, handler: () => closeShortcuts() });
|
||||||
|
registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() });
|
||||||
|
// Visible Escape entry for the help screen
|
||||||
|
registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: '<kbd>Esc</kbd>', label: 'close any overlay' });
|
||||||
|
|
||||||
|
// — Global (work everywhere unless dialog is open) —
|
||||||
|
registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>K</kbd>', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
|
||||||
|
registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>N</kbd>', label: 'quick capture (in bucket)', handler: () => { const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } } });
|
||||||
|
registerShortcut({ key: 'z', ctrl: true, guard: () => !isDialogOpen() && !guards.isOpen('modal') && canUndo(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>Z</kbd>', label: 'undo', handler: () => undo() });
|
||||||
|
registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>F1</kbd>', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
|
||||||
|
registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '<kbd>?</kbd>', label: 'this screen', handler: () => shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts() });
|
||||||
|
|
||||||
|
// — Editor (modal open) —
|
||||||
|
registerShortcut({ key: 's', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>S</kbd>', label: 'save', handler: () => saveOnly() });
|
||||||
|
registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd>', label: 'save + close', handler: () => saveCard() });
|
||||||
|
registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>↵</kbd>', label: 'save + close', handler: () => saveCard() });
|
||||||
|
// Editor element-level shortcuts listed for help screen only (handled by editor.js)
|
||||||
|
registerShortcut({ key: 'Tab', guard: () => false, section: 'editor', keys: '<kbd>⇥</kbd>', label: 'indent / title→body' });
|
||||||
|
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'move line' });
|
||||||
|
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'duplicate line' });
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<kbd>↵</kbd>', label: 'auto-continue list' });
|
||||||
|
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
|
||||||
|
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
|
||||||
|
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
|
||||||
|
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>H</kbd>', label: 'find + replace' });
|
||||||
|
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>F</kbd>', label: 'find' });
|
||||||
|
registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>H</kbd>', label: 'find + replace across cards', handler: () => openSearch({ replace: true }) });
|
||||||
|
|
||||||
|
// — Detail view —
|
||||||
|
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
|
||||||
|
const gc = getGroupCards(state.viewingCard);
|
||||||
|
if (!gc || gc.length < 2) return;
|
||||||
|
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
|
||||||
|
}});
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => !isDialogOpen() && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>↵</kbd> <span class="mouse">in preview</span>', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
|
||||||
|
|
||||||
|
// — Board navigation (body focus, no overlay) —
|
||||||
|
registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: '<kbd>A</kbd>', label: 'all cards', handler: () => activateBucket('all') });
|
||||||
|
registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: '<kbd>F</kbd>', label: 'favorites', handler: () => activateBucket('favorites') });
|
||||||
|
registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: '<kbd>N</kbd>', label: 'new card', handler: () => openModal(null) });
|
||||||
|
registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J</kbd>', label: 'today\'s journal', handler: () => {
|
||||||
|
const today = new Date().toISOString().substring(0, 10);
|
||||||
|
const existing = state.cards.find(c => (c.title || '').startsWith(today));
|
||||||
|
if (existing) openModal(existing);
|
||||||
|
else { activateBucket('journal'); openModal(null); }
|
||||||
|
}});
|
||||||
|
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
|
||||||
|
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
|
||||||
|
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
|
||||||
|
registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: '<kbd>W</kbd>', label: 'watch', handler: () => window._watchToggle?.() });
|
||||||
|
registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: '<kbd>I</kbd>', label: 'tim (time calculator)', handler: () => window._timToggle?.() });
|
||||||
|
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
|
||||||
|
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => {
|
||||||
|
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
|
||||||
|
if (!tiles.length) return;
|
||||||
|
const curIdx = tiles.findIndex(t => {
|
||||||
|
const bid = t.dataset.bucket || t.dataset.bucketSuper;
|
||||||
|
if (bid == state.activeBucket) return true;
|
||||||
|
if (t.dataset.bucketSuper) return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const dir = e.shiftKey ? -1 : 1;
|
||||||
|
const next = tiles[(curIdx + dir + tiles.length) % tiles.length];
|
||||||
|
if (next) activateBucket(next.dataset.bucketSuper ? (next.dataset.superShown || next.dataset.bucketSuper) : next.dataset.bucket);
|
||||||
|
}});
|
||||||
|
registerShortcut({ match: (e) => /^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && gBoard(), section: 'navigation', keys: '<kbd>1</kbd> – <kbd>9</kbd>', label: 'Nth user bucket', handler: (e) => {
|
||||||
|
const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||||
|
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')].filter(t => !SPECIAL.has(t.dataset.bucket));
|
||||||
|
const tile = tiles[parseInt(e.key, 10) - 1];
|
||||||
|
if (tile) activateBucket(tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// — Board arrow-key card navigation + Enter —
|
||||||
|
registerShortcut({ match: (e) => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && gBoard(), section: 'board + detail', keys: '<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd>', label: 'focus card', handler: (e) => {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
const cards = [...board.querySelectorAll('.board-card[data-id]')];
|
||||||
|
if (!cards.length) return;
|
||||||
|
const focused = board.querySelector('.card-focused');
|
||||||
|
let curIdx = focused ? cards.indexOf(focused) : -1;
|
||||||
|
if (curIdx < 0) { setCardFocus(cards[0]); return; }
|
||||||
|
const firstTop = cards[0].offsetTop;
|
||||||
|
let cols = cards.length;
|
||||||
|
for (let i = 1; i < cards.length; i++) { if (cards[i].offsetTop !== firstTop) { cols = i; break; } }
|
||||||
|
let next = curIdx;
|
||||||
|
if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1);
|
||||||
|
else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0);
|
||||||
|
else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1);
|
||||||
|
else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0);
|
||||||
|
if (next !== curIdx) setCardFocus(cards[next]);
|
||||||
|
}});
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelector('#board .card-focused'), section: 'board + detail', keys: '<kbd>↵</kbd>', label: 'focused → preview', handler: () => {
|
||||||
|
const focused = document.querySelector('#board .card-focused');
|
||||||
|
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
||||||
|
}});
|
||||||
|
// Delete selected or focused card
|
||||||
|
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && (getSelectedIds().length > 0 || !!document.querySelector('#board .card-focused')), section: 'global', keys: '<kbd>⌫</kbd>', label: 'delete selected / focused', hidden: true, handler: async () => {
|
||||||
|
let ids = getSelectedIds();
|
||||||
|
if (!ids.length) {
|
||||||
|
const focused = document.querySelector('#board .card-focused');
|
||||||
|
if (focused) ids = [focused.dataset.id];
|
||||||
|
}
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
|
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
const { animateCardRemoval, clearCardFocus } = await import('./modules/board.js');
|
||||||
|
await animateCardRemoval(ids);
|
||||||
|
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||||
|
clearSelection();
|
||||||
|
clearCardFocus();
|
||||||
|
await window._reloadAll();
|
||||||
|
}});
|
||||||
|
|
||||||
|
// — Search (help-screen-only, handled by search.js element listener) —
|
||||||
|
registerShortcut({ key: 'ArrowDown', guard: () => false, section: 'search', keys: '<kbd>↑</kbd> <kbd>↓</kbd>', label: 'navigate results' });
|
||||||
|
registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '<kbd>↵</kbd>', label: 'open selected result' });
|
||||||
|
registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: '<kbd>Esc</kbd>', label: 'close search' });
|
||||||
|
|
||||||
|
// — Board mouse actions (help-screen-only) —
|
||||||
|
registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: '<span class="mouse">click</span>', label: 'preview / split' });
|
||||||
|
registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: '<span class="mouse">dbl-click</span>', label: 'editor' });
|
||||||
|
registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: '<span class="mouse">right-click</span>', label: 'context menu' });
|
||||||
|
registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '<kbd>⌃</kbd> <span class="mouse">click</span>', label: 'multi-select' });
|
||||||
|
registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → edge</span>', label: 'reorder' });
|
||||||
|
registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → center</span>', label: 'group' });
|
||||||
|
|
||||||
|
// — Canvas (help-screen-only, handled by canvas.js overlay listener) —
|
||||||
|
for (const [k, lbl] of [['B','brush'],['E','eraser'],['L','line'],['R','rectangle'],['O','circle'],['F','fill'],['T','text cursor']]) {
|
||||||
|
registerShortcut({ key: k.toLowerCase(), guard: () => false, section: 'canvas', keys: `<kbd>${k}</kbd>`, label: lbl });
|
||||||
|
}
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '<kbd>⌫</kbd>', label: 'delete char' });
|
||||||
|
|
||||||
|
// — Mail (help-screen-only, handled by email.js document listener) —
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⌫</kbd>', label: 'delete selected mails' });
|
||||||
|
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⇧</kbd> <kbd>⌫</kbd>', label: 'delete (skip confirm)' });
|
||||||
|
|
||||||
|
let focusedCardId = null;
|
||||||
|
function setCardFocus(el) {
|
||||||
|
clearCardFocus();
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('card-focused');
|
||||||
|
focusedCardId = el.dataset.id;
|
||||||
|
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
function clearCardFocus() {
|
||||||
|
document.querySelectorAll('.card-focused').forEach(c => c.classList.remove('card-focused'));
|
||||||
|
focusedCardId = null;
|
||||||
|
}
|
||||||
|
// Clear focus when clicking anywhere on the board (a card click opens
|
||||||
|
// detail/editor and focus is no longer meaningful), or when any overlay
|
||||||
|
// opens. Also re-apply after a board re-render if the focused card is
|
||||||
|
// still in the DOM.
|
||||||
|
document.addEventListener('click', () => { if (focusedCardId) clearCardFocus(); });
|
||||||
|
window._restoreCardFocus = function () {
|
||||||
|
if (!focusedCardId) return;
|
||||||
|
const el = document.querySelector(`#board .board-card[data-id="${focusedCardId}"]`);
|
||||||
|
if (el) el.classList.add('card-focused');
|
||||||
|
else clearCardFocus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Quick Capture ──
|
||||||
|
|
||||||
|
document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (detail.classList.contains('open')) closeDetail();
|
e.stopPropagation();
|
||||||
else if (modal.classList.contains('open')) closeModal();
|
document.getElementById('quick-capture').classList.add('hidden');
|
||||||
else if (search.classList.contains('open')) closeSearch();
|
e.target.value = '';
|
||||||
else if (tasks?.classList.contains('open')) closeTasks();
|
|
||||||
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
|
if (e.key !== 'Enter') return;
|
||||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
|
const raw = e.target.value.trim();
|
||||||
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
|
if (!raw) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
// Arrow keys navigate group in detail view
|
// Split on first "|" — title before, body after.
|
||||||
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
|
// Also accept newlines as a fallback (first line = title, rest = body).
|
||||||
const gc = getGroupCards(state.viewingCard);
|
let cardTitle, body;
|
||||||
if (!gc || gc.length < 2) return;
|
const pipeIdx = raw.indexOf('|');
|
||||||
const idx = gc.findIndex(c => c.id == state.viewingCard.id);
|
if (pipeIdx >= 0) {
|
||||||
if (idx === -1) return;
|
cardTitle = raw.slice(0, pipeIdx).trim();
|
||||||
e.preventDefault();
|
body = raw.slice(pipeIdx + 1).trim();
|
||||||
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
|
} else {
|
||||||
|
const lines = raw.split(/\n/);
|
||||||
|
cardTitle = lines[0].trim();
|
||||||
|
body = lines.slice(1).join('\n').trim();
|
||||||
}
|
}
|
||||||
|
if (!cardTitle) return;
|
||||||
|
// No auto-tag — the card goes into the active bucket (or unsorted).
|
||||||
|
|
||||||
|
// Place the card in the currently active bucket (if it's a real
|
||||||
|
// user bucket). Special filters (all/current/favorites/journal/none)
|
||||||
|
// don't map to a bucket_id — cards captured there stay unsorted.
|
||||||
|
const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||||
|
const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket;
|
||||||
|
const captured = await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
|
||||||
|
if (captured?.id) state.recentlyCreatedIds?.add(String(captured.id));
|
||||||
|
|
||||||
|
e.target.value = '';
|
||||||
|
document.getElementById('quick-capture').classList.add('hidden');
|
||||||
|
await window._reloadAll();
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);';
|
||||||
|
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-lightning"></i>captured: ${cardTitle.substring(0, 40)}</div>`;
|
||||||
|
document.body.appendChild(n);
|
||||||
|
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tag quick-pick (bare "t" hotkey on the board) ──
|
||||||
|
|
||||||
|
/** Show a small popover listing every tag found on any card. Typing
|
||||||
|
* filters; arrow keys + enter pick; clicking outside or ESC closes.
|
||||||
|
* Picking a tag sets state.activeTags = [tag] and re-renders the board. */
|
||||||
|
function openTagQuickPick() {
|
||||||
|
const existing = document.getElementById('tag-quickpick');
|
||||||
|
if (existing) { existing.remove(); return; }
|
||||||
|
|
||||||
|
const tagSet = new Set();
|
||||||
|
for (const c of state.cards) {
|
||||||
|
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
|
||||||
|
}
|
||||||
|
const allTags = [...tagSet].sort();
|
||||||
|
if (!allTags.length) return;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'tag-quickpick';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;';
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="padding:10px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
|
||||||
|
<i class="ph ph-funnel text-t-accent"></i>
|
||||||
|
<input id="tag-quickpick-input" type="text" placeholder="filter tags…"
|
||||||
|
style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:6px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div id="tag-quickpick-list" style="flex:1;overflow-y:auto;padding:4px 0;"></div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = box.querySelector('#tag-quickpick-input');
|
||||||
|
const list = box.querySelector('#tag-quickpick-list');
|
||||||
|
let filtered = allTags;
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!filtered.length) {
|
||||||
|
list.innerHTML = '<div style="padding:14px 12px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no tags match</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = filtered.map((t, i) => `
|
||||||
|
<button class="tag-quickpick-row" data-tag="${escHtml(t)}" data-idx="${i}"
|
||||||
|
style="display:block;width:100%;text-align:left;padding:6px 14px;font-size:11px;font-family:var(--t-font-pixel,'Silkscreen',cursive);color:${i === active ? 'var(--t-bright)' : 'var(--t-text)'};background:${i === active ? 'var(--t-raised)' : 'transparent'};border:none;cursor:pointer;">
|
||||||
|
#${escHtml(t)}
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
list.querySelectorAll('[data-tag]').forEach(btn => {
|
||||||
|
btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); });
|
||||||
|
btn.addEventListener('click', () => pick(btn.dataset.tag));
|
||||||
|
});
|
||||||
|
const activeEl = list.querySelector(`[data-idx="${active}"]`);
|
||||||
|
activeEl?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(tag) {
|
||||||
|
state.activeTags = [tag];
|
||||||
|
close();
|
||||||
|
window._renderTagBar?.();
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const f = input.value.toLowerCase().replace(/^#/, '');
|
||||||
|
filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags;
|
||||||
|
active = 0;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
|
// Capture-phase keydown so ESC etc. don't bubble back to the rest of
|
||||||
|
// the page handlers (search, board) while the picker is open.
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot ──
|
// ── Boot ──
|
||||||
|
|
||||||
|
renderShortcutHelp();
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
// ── Test runner (lazy-loaded, no cost in production) ──
|
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||||
if (new URLSearchParams(location.search).has('test') || false) {
|
|
||||||
import('./test/tests.js');
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
|
||||||
e.preventDefault();
|
|
||||||
import('./test/tests.js').then(() => import('./test/runner.js').then(m => m.open()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// ── hyprcrd Plugin API ──
|
||||||
|
//
|
||||||
|
// Loaded synchronously BEFORE any module script. Plugins and core both
|
||||||
|
// register against window.hyprcrd. Plugins MUST NOT import from core
|
||||||
|
// modules — use this global API instead so they remain decoupled.
|
||||||
|
//
|
||||||
|
// Available namespaces:
|
||||||
|
// hyprcrd.hooks — generic event bus (on/emit)
|
||||||
|
// hyprcrd.editor — toolbar command registry
|
||||||
|
// hyprcrd.markdown — fenced block + post-processing registry
|
||||||
|
// hyprcrd.util — DOM helpers exposed for plugin use
|
||||||
|
//
|
||||||
|
// Plugins are loaded as <script type="module"> tags after this script
|
||||||
|
// but before app.js. They self-register on module evaluation; the core
|
||||||
|
// then queries the registries when needed.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Generic event bus ───────────────────────────────────────────
|
||||||
|
const hookListeners = Object.create(null);
|
||||||
|
const hooks = {
|
||||||
|
on(name, fn) {
|
||||||
|
if (typeof fn !== 'function') return;
|
||||||
|
(hookListeners[name] ||= []).push(fn);
|
||||||
|
},
|
||||||
|
off(name, fn) {
|
||||||
|
const arr = hookListeners[name];
|
||||||
|
if (!arr) return;
|
||||||
|
const idx = arr.indexOf(fn);
|
||||||
|
if (idx >= 0) arr.splice(idx, 1);
|
||||||
|
},
|
||||||
|
emit(name, data) {
|
||||||
|
const arr = hookListeners[name];
|
||||||
|
if (!arr) return data;
|
||||||
|
for (const fn of arr.slice()) {
|
||||||
|
try {
|
||||||
|
const r = fn(data);
|
||||||
|
if (r !== undefined) data = r;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[hyprcrd hook ${name}]`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Editor command registry ─────────────────────────────────────
|
||||||
|
// Toolbar buttons with `data-md="<name>"` dispatch to handlers
|
||||||
|
// registered here. Plugins inject buttons + register handlers.
|
||||||
|
const editorCommands = Object.create(null);
|
||||||
|
const editor = {
|
||||||
|
registerCommand(name, fn) {
|
||||||
|
if (typeof fn !== 'function') return;
|
||||||
|
editorCommands[name] = fn;
|
||||||
|
},
|
||||||
|
unregisterCommand(name) {
|
||||||
|
delete editorCommands[name];
|
||||||
|
},
|
||||||
|
getCommand(name) {
|
||||||
|
return editorCommands[name];
|
||||||
|
},
|
||||||
|
// Set by editor.js after init — gives plugins access to
|
||||||
|
// insertMd(before, after, placeholder) and the textarea body
|
||||||
|
// without forcing an import dependency.
|
||||||
|
api: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Markdown fenced block registry ──────────────────────────────
|
||||||
|
// Each entry: { lang: string, handler: (content) => string }
|
||||||
|
// The handler receives the raw text inside ```<lang> ... ``` and
|
||||||
|
// must return safe HTML (it will pass through DOMPurify after).
|
||||||
|
const fencedBlocks = [];
|
||||||
|
// Pre/post processors run on the full source text, before/after
|
||||||
|
// marked.parse(). Useful for non-fenced syntax extensions.
|
||||||
|
const preProcessors = [];
|
||||||
|
const postProcessors = [];
|
||||||
|
const markdown = {
|
||||||
|
registerFencedBlock(lang, handler) {
|
||||||
|
if (typeof handler !== 'function') return;
|
||||||
|
fencedBlocks.push({ lang, handler });
|
||||||
|
},
|
||||||
|
getFencedBlocks() {
|
||||||
|
return fencedBlocks.slice();
|
||||||
|
},
|
||||||
|
registerPreProcessor(fn) {
|
||||||
|
if (typeof fn === 'function') preProcessors.push(fn);
|
||||||
|
},
|
||||||
|
getPreProcessors() {
|
||||||
|
return preProcessors.slice();
|
||||||
|
},
|
||||||
|
registerPostProcessor(fn) {
|
||||||
|
if (typeof fn === 'function') postProcessors.push(fn);
|
||||||
|
},
|
||||||
|
getPostProcessors() {
|
||||||
|
return postProcessors.slice();
|
||||||
|
},
|
||||||
|
// Filled in by core markdown.js after load
|
||||||
|
render: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||||
|
let csrfToken = '';
|
||||||
|
const util = {
|
||||||
|
$(idOrSel) {
|
||||||
|
if (typeof idOrSel !== 'string') return null;
|
||||||
|
if (idOrSel.startsWith('#') || idOrSel.includes(' ') || idOrSel.includes('.') || idOrSel.includes('[')) {
|
||||||
|
return document.querySelector(idOrSel);
|
||||||
|
}
|
||||||
|
return document.getElementById(idOrSel);
|
||||||
|
},
|
||||||
|
$$(sel) {
|
||||||
|
return Array.from(document.querySelectorAll(sel));
|
||||||
|
},
|
||||||
|
esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
},
|
||||||
|
// CSRF: core calls setCsrf() after auth so plugins can read it.
|
||||||
|
// Emits 'csrf.ready' so plugins that need to make state-changing
|
||||||
|
// requests on init can defer until auth has happened.
|
||||||
|
setCsrf(token) {
|
||||||
|
csrfToken = String(token || '');
|
||||||
|
if (csrfToken) hooks.emit('csrf.ready', csrfToken);
|
||||||
|
},
|
||||||
|
csrf() { return csrfToken; },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Generic JSON API call with automatic CSRF header.
|
||||||
|
* On 401 (session expired while the UI is open) we call the app's
|
||||||
|
* re-auth prompt once, pick up the refreshed CSRF token, and retry
|
||||||
|
* the original request so the caller's save / poll succeeds instead
|
||||||
|
* of silently returning an unauthorized error payload. */
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const doFetch = () => {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
return fetch(path, {
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings registry ───────────────────────────────────────────
|
||||||
|
// Plugins can inject their own tabs into the main settings overlay.
|
||||||
|
// The plugin's onShow callback is called whenever the tab becomes visible.
|
||||||
|
const settings = {
|
||||||
|
registerTab({ id, label, icon, onShow }) {
|
||||||
|
if (!id) return;
|
||||||
|
const nav = document.getElementById('settings-nav');
|
||||||
|
const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
|
||||||
|
if (!nav || !panelsContainer) {
|
||||||
|
// DOM not ready — defer to next tick
|
||||||
|
requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
|
||||||
|
|
||||||
|
// Create tab button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'settings-tab';
|
||||||
|
btn.dataset.tab = id;
|
||||||
|
const iconClass = icon || 'ph-puzzle-piece';
|
||||||
|
btn.innerHTML = `<i class="ph ${iconClass} text-sm"></i><span class="font-pixel text-[10px]">${label || id}</span>`;
|
||||||
|
nav.appendChild(btn);
|
||||||
|
|
||||||
|
// Create empty panel container
|
||||||
|
let panel = document.getElementById(`settings-panel-${id}`);
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = `settings-panel-${id}`;
|
||||||
|
panel.className = 'hidden';
|
||||||
|
panelsContainer.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler — duplicates core tab logic (theme.js may also
|
||||||
|
// attach its own handler later, but identical work is harmless).
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
if (typeof onShow === 'function') {
|
||||||
|
try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); }
|
||||||
|
}
|
||||||
|
hooks.emit('settings.tab.shown', { tab: id, panel });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the main settings overlay. */
|
||||||
|
open() {
|
||||||
|
document.getElementById('settings-btn')?.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the settings overlay and switch to a specific tab. */
|
||||||
|
openTab(id) {
|
||||||
|
this.open();
|
||||||
|
// Settings opens via click; the tab might need a frame to render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dialog API (filled in by core dialog.js after load) ─────────
|
||||||
|
// Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...)
|
||||||
|
// instead of native browser confirm()/prompt() so the UI stays themed
|
||||||
|
// and consistent.
|
||||||
|
const dialog = {
|
||||||
|
show: null, // (opts) => Promise<value|null> — set by core dialog.js
|
||||||
|
/** Convenience confirm wrapper. Returns Promise<true|null>. */
|
||||||
|
async confirm(message, opts = {}) {
|
||||||
|
if (typeof dialog.show !== 'function') {
|
||||||
|
// Core dialog not loaded yet — fall back to native
|
||||||
|
return window.confirm(message) ? true : null;
|
||||||
|
}
|
||||||
|
return dialog.show({
|
||||||
|
title: opts.title || 'confirm',
|
||||||
|
message,
|
||||||
|
input: false,
|
||||||
|
danger: opts.danger ?? true,
|
||||||
|
okLabel: opts.okLabel || 'Ok',
|
||||||
|
cancelLabel: opts.cancelLabel || 'Cancel',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Plugin metadata registry ────────────────────────────────────
|
||||||
|
// Plugins can register themselves here for introspection / debug.
|
||||||
|
const plugins = [];
|
||||||
|
const registerPlugin = (meta) => {
|
||||||
|
if (meta && meta.name) plugins.push(meta);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.hyprcrd = {
|
||||||
|
hooks,
|
||||||
|
editor,
|
||||||
|
markdown,
|
||||||
|
util,
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
dialog,
|
||||||
|
plugins,
|
||||||
|
registerPlugin,
|
||||||
|
version: '1.0',
|
||||||
|
};
|
||||||
|
})();
|
||||||
+292
-38
@@ -23,6 +23,82 @@ const boardPreviewTags = $('board-preview-tags');
|
|||||||
const boardPreviewMeta = $('board-preview-meta');
|
const boardPreviewMeta = $('board-preview-meta');
|
||||||
let activeDragEl = null;
|
let activeDragEl = null;
|
||||||
let dragGhost = null;
|
let dragGhost = null;
|
||||||
|
// Set of card ids currently being dragged. Module-scoped so a poll-driven
|
||||||
|
// re-render (caldav/collab) can re-apply the `.card-dragging` ghost class
|
||||||
|
// to the matching DOM nodes after `board.innerHTML = …` wipes them. Without
|
||||||
|
// this the dragged card would visually pop back to its normal state every
|
||||||
|
// time a background poll completed mid-drag.
|
||||||
|
const draggingCardIds = new Set();
|
||||||
|
|
||||||
|
// Tracks every card id we've ever rendered. On the first render we just
|
||||||
|
// snapshot whatever's currently in state.cards (everything counts as
|
||||||
|
// "already known"); after that, the diff against this set tells us
|
||||||
|
// which cards just arrived from a poll / sync. New cards get pushed into
|
||||||
|
// state.unseenCardIds and either pulse immediately (if we're already on
|
||||||
|
// the all view) or wait for the next visit to the all tab to pulse.
|
||||||
|
let knownCardIds = null;
|
||||||
|
|
||||||
|
/** Animate card removal: adds .card-removing to each matching card
|
||||||
|
* element and waits for the CSS animation to finish before resolving.
|
||||||
|
* Call this BEFORE the API delete + _reloadAll so the user sees the
|
||||||
|
* card shrink/fade out instead of just vanishing. */
|
||||||
|
export function animateCardRemoval(ids) {
|
||||||
|
if (!ids || !ids.length) return Promise.resolve();
|
||||||
|
const els = ids.map(id => board.querySelector(`[data-id="${id}"]`)).filter(Boolean);
|
||||||
|
if (!els.length) return Promise.resolve();
|
||||||
|
for (const el of els) el.classList.add('card-removing');
|
||||||
|
return new Promise(r => setTimeout(r, 260));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ghost-and-spinner overlay for cards that are being moved between
|
||||||
|
* buckets. The PATCH + reloadAll round-trip used to leave the original
|
||||||
|
* tile sitting stale in the current bucket for a couple hundred ms;
|
||||||
|
* marking them gives immediate feedback. The class comes off naturally
|
||||||
|
* on the next renderBoard() (the DOM is rebuilt from fresh state). */
|
||||||
|
export function markCardsMoving(ids) {
|
||||||
|
if (!ids || !ids.length) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
|
if (el) el.classList.add('card-moving');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._markCardsMoving = markCardsMoving;
|
||||||
|
|
||||||
|
/** Add a pulse class to matching board cards and strip it after the
|
||||||
|
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
||||||
|
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
||||||
|
* just created themselves). */
|
||||||
|
function pulseCards(ids, cls = 'card-pulse-new') {
|
||||||
|
if (!ids || !ids.size) return;
|
||||||
|
const durationMs = cls === 'card-pulse-local' ? 900 : 2400;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
|
if (!el) continue;
|
||||||
|
el.classList.add(cls);
|
||||||
|
setTimeout(() => el.classList.remove(cls), durationMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden
|
||||||
|
* while the user is currently looking at the all view. Lives in
|
||||||
|
* board.js (rather than buckets.js) because the trigger is "new card
|
||||||
|
* arrived" — buckets.js doesn't know about that, board.js does. */
|
||||||
|
function decorateAllUnreadDot() {
|
||||||
|
const allTab = document.querySelector('#bucket-bar [data-bucket="all"]');
|
||||||
|
if (!allTab) return;
|
||||||
|
const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all';
|
||||||
|
allTab.classList.toggle('has-unread', hasUnread);
|
||||||
|
let dot = allTab.querySelector('.bar-unread-dot');
|
||||||
|
if (hasUnread && !dot) {
|
||||||
|
dot = document.createElement('span');
|
||||||
|
dot.className = 'bar-unread-dot';
|
||||||
|
allTab.appendChild(dot);
|
||||||
|
} else if (!hasUnread && dot) {
|
||||||
|
dot.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Multi-select ──
|
// ── Multi-select ──
|
||||||
const selectedIds = state.selectedIds;
|
const selectedIds = state.selectedIds;
|
||||||
@@ -60,6 +136,7 @@ function toggleSelect(id) {
|
|||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
board.querySelectorAll('.card-selected').forEach(el => {
|
board.querySelectorAll('.card-selected').forEach(el => {
|
||||||
el.classList.remove('card-selected');
|
el.classList.remove('card-selected');
|
||||||
el.querySelector('.card-select-check')?.remove();
|
el.querySelector('.card-select-check')?.remove();
|
||||||
@@ -70,21 +147,9 @@ function clearSelection() {
|
|||||||
export function getSelectedIds() { return [...selectedIds]; }
|
export function getSelectedIds() { return [...selectedIds]; }
|
||||||
export { clearSelection };
|
export { clearSelection };
|
||||||
|
|
||||||
// Delete/Backspace deletes selected cards
|
// Delete/Backspace for selected cards is now in the central shortcuts
|
||||||
document.addEventListener('keydown', async e => {
|
// registry (app.js registerShortcut). It uses getSelectedIds() /
|
||||||
if (!selectedIds.size) return;
|
// clearSelection() which are already exported.
|
||||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
|
||||||
e.preventDefault();
|
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
|
||||||
const { showDialog } = await import('./dialog.js');
|
|
||||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' });
|
|
||||||
clearSelection();
|
|
||||||
await window._reloadAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selection on click outside board (header, footer, etc)
|
// Clear selection on click outside board (header, footer, etc)
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
@@ -111,6 +176,16 @@ $('select-group')?.addEventListener('click', async () => {
|
|||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('select-join')?.addEventListener('click', async () => {
|
||||||
|
if (selectedIds.size < 2) return;
|
||||||
|
// Dynamic import keeps the board → context-menu edge lazy and avoids
|
||||||
|
// circular import surprises at module load time. joinSelectedCards
|
||||||
|
// reads state.selectedIds + state.selectionWasBulk and handles its
|
||||||
|
// own reload + selection clear.
|
||||||
|
const { joinSelectedCards } = await import('./context-menu.js');
|
||||||
|
await joinSelectedCards();
|
||||||
|
});
|
||||||
|
|
||||||
$('select-bucket')?.addEventListener('click', () => {
|
$('select-bucket')?.addEventListener('click', () => {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
// Remove existing picker
|
// Remove existing picker
|
||||||
@@ -176,7 +251,7 @@ $('select-bucket')?.addEventListener('click', () => {
|
|||||||
$('select-color')?.addEventListener('click', async () => {
|
$('select-color')?.addEventListener('click', async () => {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
const { showDialog } = await import('./dialog.js');
|
const { showDialog } = await import('./dialog.js');
|
||||||
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'apply' });
|
const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'Apply' });
|
||||||
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
|
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return;
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
|
await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } });
|
||||||
@@ -188,7 +263,7 @@ $('select-color')?.addEventListener('click', async () => {
|
|||||||
$('select-tag')?.addEventListener('click', async () => {
|
$('select-tag')?.addEventListener('click', async () => {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
const { showDialog } = await import('./dialog.js');
|
const { showDialog } = await import('./dialog.js');
|
||||||
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'add' });
|
const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'Add' });
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
const cleanTag = tag.trim().replace(/^#/, '');
|
const cleanTag = tag.trim().replace(/^#/, '');
|
||||||
if (!cleanTag) return;
|
if (!cleanTag) return;
|
||||||
@@ -215,7 +290,7 @@ $('select-delete')?.addEventListener('click', async () => {
|
|||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
const { showDialog } = await import('./dialog.js');
|
const { showDialog } = await import('./dialog.js');
|
||||||
const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' });
|
const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
@@ -295,8 +370,11 @@ function closeBoardPreview() {
|
|||||||
boardPreview?.classList.add('hidden');
|
boardPreview?.classList.add('hidden');
|
||||||
state.boardSplitOpen = false;
|
state.boardSplitOpen = false;
|
||||||
localStorage.setItem('hyprcrd-board-split', '0');
|
localStorage.setItem('hyprcrd-board-split', '0');
|
||||||
|
// The toolbar's active state lives on the `.active` class — the
|
||||||
|
// previous `text-t-accent` removal was a no-op since that class
|
||||||
|
// isn't what toggleBoardSplit uses.
|
||||||
const btn = $('board-split-toggle');
|
const btn = $('board-split-toggle');
|
||||||
if (btn) btn.classList.remove('text-t-accent');
|
if (btn) btn.classList.remove('active');
|
||||||
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
||||||
state.boardPreviewCardId = null;
|
state.boardPreviewCardId = null;
|
||||||
resetBoardPreview();
|
resetBoardPreview();
|
||||||
@@ -418,18 +496,59 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
${loaded
|
${loaded
|
||||||
? (localStorage.getItem('hyprcrd-body-lines') === '0' ? '' : `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed flex-1" style="-webkit-line-clamp:var(--card-body-lines,5);display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;">${esc(stripMd(c.body))}</p>`)
|
? (localStorage.getItem('hyprcrd-body-lines') === '0' ? '' : `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed flex-1" style="-webkit-line-clamp:var(--card-body-lines,5);display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;">${esc(stripMd(c.body))}</p>`)
|
||||||
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
|
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
|
||||||
${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `<div class="card-tags flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||||
|
${Number(c.read_push) ? '<span class="flex items-center gap-1 text-t-accent" title="On your relay\'s Read list"><i class="ph ph-book-open text-xs"></i></span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
//
|
||||||
|
// Public renderBoard() is coalesced via requestAnimationFrame: multiple
|
||||||
|
// callers in the same tick (cache paint → meta paint → body paint →
|
||||||
|
// plugin-hook re-render → ...) collapse into a single actual render on
|
||||||
|
// the next frame. Dropped the "three innerHTML swaps per reload" cost
|
||||||
|
// that showed up as the "Gekringel" during boot. Callers that need the
|
||||||
|
// DOM in a specific state before doing something else can call
|
||||||
|
// renderBoardSync() explicitly — but in practice every existing caller
|
||||||
|
// just wants the board to reflect the latest state.
|
||||||
|
|
||||||
|
let rafScheduled = 0;
|
||||||
export function renderBoard() {
|
export function renderBoard() {
|
||||||
|
if (rafScheduled) return;
|
||||||
|
rafScheduled = requestAnimationFrame(() => {
|
||||||
|
rafScheduled = 0;
|
||||||
|
renderBoardSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderBoardSync() {
|
||||||
|
if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; }
|
||||||
|
renderBoardImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardImpl() {
|
||||||
|
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||||
|
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||||
|
// (initial empty state, before the API has returned), so we wait
|
||||||
|
// until knownCardIds is non-empty before doing the diff — otherwise
|
||||||
|
// the first real load looks like "every card just arrived" and the
|
||||||
|
// entire board pulses on page open.
|
||||||
|
const currentIds = new Set(state.cards.map(c => String(c.id)));
|
||||||
|
const newLocalIds = new Set();
|
||||||
|
if (knownCardIds && knownCardIds.size > 0) {
|
||||||
|
for (const id of currentIds) {
|
||||||
|
if (knownCardIds.has(id)) continue;
|
||||||
|
// Locally-created cards go into a separate set — they get a
|
||||||
|
// 1× pulse but must NOT trigger the unread dot on the all-tab.
|
||||||
|
if (state.recentlyCreatedIds.has(id)) newLocalIds.add(id);
|
||||||
|
else state.unseenCardIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
knownCardIds = currentIds;
|
||||||
let cards;
|
let cards;
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
@@ -502,10 +621,21 @@ export function renderBoard() {
|
|||||||
renderGroupBar(cards);
|
renderGroupBar(cards);
|
||||||
|
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
board.style.display = '';
|
board.style.display = 'flex';
|
||||||
board.innerHTML = `<div class="col-span-full text-center py-20">
|
board.style.alignItems = 'center';
|
||||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
board.style.justifyContent = 'center';
|
||||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
// Distinguish "genuinely empty" from "haven't heard back yet".
|
||||||
|
// Blurting "no cards found" during the initial load is scary
|
||||||
|
// on boards the user knows aren't empty.
|
||||||
|
if (!state.cardsLoaded) {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
|
||||||
|
} else {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||||
|
}
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
||||||
return;
|
return;
|
||||||
@@ -515,10 +645,9 @@ export function renderBoard() {
|
|||||||
const inGroup = !!state.activeGroup;
|
const inGroup = !!state.activeGroup;
|
||||||
|
|
||||||
if (state.superView) {
|
if (state.superView) {
|
||||||
// Compact multi-column list view — override board grid
|
// Single-column list view — override board grid
|
||||||
board.style.display = 'block';
|
board.style.display = 'block';
|
||||||
const cols = cards.length <= 20 ? 2 : cards.length <= 50 ? 3 : cards.length <= 100 ? 4 : 5;
|
board.innerHTML = `<div class="superview-grid">` + cards.map(c => {
|
||||||
board.innerHTML = `<div class="superview-grid" style="column-count:${cols};">` + cards.map(c => {
|
|
||||||
const tags = parseTags(c);
|
const tags = parseTags(c);
|
||||||
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
const isTermin = tags.includes('termin') || tags.includes('geburtstag');
|
||||||
let calCfg = null;
|
let calCfg = null;
|
||||||
@@ -526,10 +655,26 @@ export function renderBoard() {
|
|||||||
const dotColor = calCfg?.color || c.color;
|
const dotColor = calCfg?.color || c.color;
|
||||||
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null);
|
||||||
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
|
const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)');
|
||||||
|
// Date column: due_date if set, else date prefix in title, else updated/created
|
||||||
|
const dateStr = c.due_date
|
||||||
|
|| ((c.title || '').match(/^\d{4}-\d{2}-\d{2}/)?.[0])
|
||||||
|
|| (c.updated_at || c.created_at || '').substring(0, 10);
|
||||||
|
// Tag chips (excluding termin/geburtstag — those are already
|
||||||
|
// represented by the icon + dot on the left)
|
||||||
|
const visibleTags = tags.filter(t => t !== 'termin' && t !== 'geburtstag');
|
||||||
|
const tagsHtml = visibleTags.length
|
||||||
|
? `<span class="superview-tags">${visibleTags.slice(0, 4).map(t => `<span class="superview-tag">#${esc(t)}</span>`).join('')}${visibleTags.length > 4 ? `<span class="superview-tag superview-tag-more">+${visibleTags.length - 4}</span>` : ''}</span>`
|
||||||
|
: '';
|
||||||
|
const bucketHtml = c.bucket_name
|
||||||
|
? `<span class="superview-bucket"><i class="ph ph-folder"></i>${esc(c.bucket_name)}</span>`
|
||||||
|
: '';
|
||||||
return `<div class="superview-item" data-id="${c.id}">
|
return `<div class="superview-item" data-id="${c.id}">
|
||||||
<span class="superview-dot" style="background:${dotColor}"></span>
|
<span class="superview-dot" style="background:${dotColor}"></span>
|
||||||
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
|
||||||
<span class="superview-title">${esc(c.title)}</span>
|
<span class="superview-title">${esc(c.title)}</span>
|
||||||
|
${tagsHtml}
|
||||||
|
${bucketHtml}
|
||||||
|
<span class="superview-date">${dateStr}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
|
|
||||||
@@ -610,10 +755,25 @@ export function renderBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
board.style.display = '';
|
board.style.display = '';
|
||||||
|
board.style.alignItems = '';
|
||||||
|
board.style.justifyContent = '';
|
||||||
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
||||||
|
|
||||||
bindCardEvents();
|
bindCardEvents();
|
||||||
|
|
||||||
|
// Re-apply the dragging ghost to any card whose drag is still in
|
||||||
|
// flight. Polls (caldav, collab) call _reloadAll → renderBoard mid-
|
||||||
|
// drag, which would otherwise wipe the visual ghost state without
|
||||||
|
// the underlying drag operation noticing.
|
||||||
|
if (draggingCardIds.size) {
|
||||||
|
draggingCardIds.forEach(did => {
|
||||||
|
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore arrow-key card focus if the focused card is still rendered
|
||||||
|
window._restoreCardFocus?.();
|
||||||
|
|
||||||
// Restore selection state
|
// Restore selection state
|
||||||
selectedIds.forEach(id => {
|
selectedIds.forEach(id => {
|
||||||
const el = board.querySelector(`[data-id="${id}"]`);
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
@@ -641,6 +801,38 @@ export function renderBoard() {
|
|||||||
|
|
||||||
// Notification banner
|
// Notification banner
|
||||||
renderNotifs();
|
renderNotifs();
|
||||||
|
|
||||||
|
// Pulse locally-created cards in ANY bucket (1× subtle pulse).
|
||||||
|
// These never enter unseenCardIds so they can't trigger the all-tab
|
||||||
|
// dot — the user just made them, there's nothing "unseen" about it.
|
||||||
|
// Journal entries are exempt from the pulse entirely — they're daily
|
||||||
|
// log jottings, not "here's a new thing" moments, so the flash
|
||||||
|
// reads as noise. Detection mirrors calendar.js isJournal: title
|
||||||
|
// begins with YYYY-MM-DD, no termin/geburtstag tag, no caldav_uid,
|
||||||
|
// and no " — <summary>" suffix (which denotes an appointment).
|
||||||
|
if (newLocalIds.size) {
|
||||||
|
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
|
||||||
|
const toPulse = new Set();
|
||||||
|
for (const id of newLocalIds) {
|
||||||
|
const card = state.cards.find(c => String(c.id) === id);
|
||||||
|
if (!card) continue;
|
||||||
|
const isJournal = /^\d{4}-\d{2}-\d{2}$/.test((card.title || '').trim())
|
||||||
|
&& !/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title || '')
|
||||||
|
&& !card.caldav_uid
|
||||||
|
&& !(card.tags || '').split(',').some(t => t === 'termin' || t === 'geburtstag');
|
||||||
|
if (!isJournal) toPulse.add(id);
|
||||||
|
}
|
||||||
|
if (toPulse.size) pulseCards(toPulse, 'card-pulse-local');
|
||||||
|
}
|
||||||
|
// Remote (relay-synced) cards: pulse 3× only when the user is on
|
||||||
|
// the all view. On other views, the unseen ids accumulate and
|
||||||
|
// the all-tab gets a dot until the user opens it.
|
||||||
|
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
|
||||||
|
const remote = new Set(state.unseenCardIds);
|
||||||
|
state.unseenCardIds.clear();
|
||||||
|
pulseCards(remote, 'card-pulse-new');
|
||||||
|
}
|
||||||
|
decorateAllUnreadDot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification providers ──
|
// ── Notification providers ──
|
||||||
@@ -771,9 +963,10 @@ function renderGroupBar(cards) {
|
|||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
bar.classList.remove('hidden');
|
bar.classList.remove('hidden');
|
||||||
|
bar.dataset.groupId = String(state.activeGroup);
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<button id="group-back" 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">
|
<button id="group-back" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||||
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">back</span>
|
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Back</span>
|
||||||
</button>
|
</button>
|
||||||
<i class="ph ph-stack text-t-accent text-sm"></i>
|
<i class="ph ph-stack text-t-accent text-sm"></i>
|
||||||
<span class="font-pixel text-[10px] text-t-accent">${esc(parent?.title || 'group')}</span>
|
<span class="font-pixel text-[10px] text-t-accent">${esc(parent?.title || 'group')}</span>
|
||||||
@@ -781,6 +974,7 @@ function renderGroupBar(cards) {
|
|||||||
$('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); });
|
$('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); });
|
||||||
} else {
|
} else {
|
||||||
bar.classList.add('hidden');
|
bar.classList.add('hidden');
|
||||||
|
delete bar.dataset.groupId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +1035,36 @@ function hideFan() {
|
|||||||
// ── Card event binding ──
|
// ── Card event binding ──
|
||||||
|
|
||||||
function bindCardEvents() {
|
function bindCardEvents() {
|
||||||
|
// Single insertion-cursor element that lives inside #board (which is
|
||||||
|
// `relative`) and gets repositioned by the card-level dragover handlers
|
||||||
|
// below. Replaces the previous `.card-drop-before::before` /
|
||||||
|
// `.card-drop-after::after` pseudo-elements so the cursor is a real
|
||||||
|
// element in the gap rather than a sliver glued to the side of a tile.
|
||||||
|
let cardDropCursor = board.querySelector('#card-drop-cursor');
|
||||||
|
if (!cardDropCursor) {
|
||||||
|
cardDropCursor = document.createElement('div');
|
||||||
|
cardDropCursor.id = 'card-drop-cursor';
|
||||||
|
board.appendChild(cardDropCursor);
|
||||||
|
}
|
||||||
|
function showCardDropCursor(targetEl, before) {
|
||||||
|
// offsetLeft / offsetTop / offsetHeight are relative to the
|
||||||
|
// offsetParent (#board) so the cursor positions correctly even
|
||||||
|
// when #board is scrolled vertically.
|
||||||
|
const x = before
|
||||||
|
? targetEl.offsetLeft - 6
|
||||||
|
: targetEl.offsetLeft + targetEl.offsetWidth + 2;
|
||||||
|
cardDropCursor.style.left = x + 'px';
|
||||||
|
cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px';
|
||||||
|
cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px';
|
||||||
|
cardDropCursor.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideCardDropCursor() {
|
||||||
|
cardDropCursor.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Expose so the global dragend handler (defined outside this function)
|
||||||
|
// can hide the cursor too.
|
||||||
|
board._hideCardDropCursor = hideCardDropCursor;
|
||||||
|
|
||||||
board.querySelectorAll('[data-group]').forEach(badge => {
|
board.querySelectorAll('[data-group]').forEach(badge => {
|
||||||
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
||||||
});
|
});
|
||||||
@@ -930,8 +1154,11 @@ function bindCardEvents() {
|
|||||||
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
||||||
const dragIds = isMulti ? [...selectedIds] : [String(id)];
|
const dragIds = isMulti ? [...selectedIds] : [String(id)];
|
||||||
|
|
||||||
// Ghost all dragged cards
|
// Ghost all dragged cards + remember the ids so a poll-driven
|
||||||
|
// re-render can re-apply the ghost state.
|
||||||
|
draggingCardIds.clear();
|
||||||
dragIds.forEach(did => {
|
dragIds.forEach(did => {
|
||||||
|
draggingCardIds.add(String(did));
|
||||||
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -977,20 +1204,36 @@ function bindCardEvents() {
|
|||||||
el.addEventListener('dragend', () => {
|
el.addEventListener('dragend', () => {
|
||||||
// Remove ghost class from all dragged cards
|
// Remove ghost class from all dragged cards
|
||||||
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
||||||
|
draggingCardIds.clear();
|
||||||
activeDragEl = null;
|
activeDragEl = null;
|
||||||
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
||||||
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
|
board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group'));
|
||||||
x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
hideCardDropCursor();
|
||||||
});
|
});
|
||||||
el.addEventListener('dragover', e => {
|
el.addEventListener('dragover', e => {
|
||||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
||||||
el.classList.toggle('card-drop-before', x < edge);
|
// Three zones (matching the bucket fix): left 25% / right 25%
|
||||||
el.classList.toggle('card-drop-after', x > rect.width - edge);
|
// → insertion cursor in the gap, center 50% → group highlight
|
||||||
el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
|
// on the target card.
|
||||||
|
const isBefore = x < edge;
|
||||||
|
const isAfter = x > rect.width - edge;
|
||||||
|
const isGroup = !isBefore && !isAfter;
|
||||||
|
el.classList.toggle('card-drop-group', isGroup);
|
||||||
|
if (isGroup) {
|
||||||
|
hideCardDropCursor();
|
||||||
|
} else {
|
||||||
|
showCardDropCursor(el, isBefore);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el.addEventListener('dragleave', () => {
|
||||||
|
el.classList.remove('card-drop-group');
|
||||||
|
// Don't hide the cursor here — the next card's dragover will
|
||||||
|
// reposition it. Hiding on dragleave makes the cursor flicker
|
||||||
|
// as the mouse moves across adjacent cards. Final cleanup
|
||||||
|
// happens on dragend.
|
||||||
});
|
});
|
||||||
el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
|
||||||
el.addEventListener('drop', e => handleCardDrop(e, el, id));
|
el.addEventListener('drop', e => handleCardDrop(e, el, id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -999,9 +1242,17 @@ function bindCardEvents() {
|
|||||||
|
|
||||||
async function handleCardDrop(e, el, targetId) {
|
async function handleCardDrop(e, el, targetId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const wasGroup = el.classList.contains('card-drop-group');
|
// Recompute the drop zone from the mouse position rather than reading
|
||||||
const wasBefore = el.classList.contains('card-drop-before');
|
// it back from CSS classes — the new cursor-based dragover doesn't
|
||||||
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
|
// tag the target with `.card-drop-before` / `.card-drop-after` anymore,
|
||||||
|
// only `.card-drop-group` for the center hover state.
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const edge = rect.width * 0.25;
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const wasGroup = !(x < edge) && !(x > rect.width - edge);
|
||||||
|
const wasBefore = x < edge;
|
||||||
|
el.classList.remove('card-drop-group');
|
||||||
|
board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none');
|
||||||
const dragId = e.dataTransfer.getData('text/plain');
|
const dragId = e.dataTransfer.getData('text/plain');
|
||||||
if (!dragId || dragId === targetId) return;
|
if (!dragId || dragId === targetId) return;
|
||||||
|
|
||||||
@@ -1103,6 +1354,9 @@ board.addEventListener('mousedown', e => {
|
|||||||
if (!boxActive) {
|
if (!boxActive) {
|
||||||
boxActive = true;
|
boxActive = true;
|
||||||
if (!additive) clearSelection();
|
if (!additive) clearSelection();
|
||||||
|
// Mark the selection as bulk so the join action knows to use
|
||||||
|
// the longest-title strategy instead of first-clicked.
|
||||||
|
state.selectionWasBulk = true;
|
||||||
boxEl = document.createElement('div');
|
boxEl = document.createElement('div');
|
||||||
boxEl.className = 'box-select';
|
boxEl.className = 'box-select';
|
||||||
board.appendChild(boxEl);
|
board.appendChild(boxEl);
|
||||||
|
|||||||
+449
-55
@@ -1,8 +1,9 @@
|
|||||||
// ── Bucket management ──
|
// ── Bucket management ──
|
||||||
|
|
||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, CARD_COLORS } from './state.js';
|
import { state, CARD_COLORS, setActiveBucket, ensureActiveBucketValid } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
import { pushUndo } from './undo.js';
|
||||||
|
|
||||||
// ── Bucket context menu ──
|
// ── Bucket context menu ──
|
||||||
|
|
||||||
@@ -19,22 +20,38 @@ document.addEventListener('click', hideBucketCtx);
|
|||||||
|
|
||||||
async function showBucketCtx(x, y, bucket) {
|
async function showBucketCtx(x, y, bucket) {
|
||||||
ctxBucketId = bucket.id;
|
ctxBucketId = bucket.id;
|
||||||
const idx = state.buckets.findIndex(b => b.id == bucket.id);
|
// Move-left / move-right operates within the bucket's siblings —
|
||||||
|
// top-level peers if the bucket is top-level, dropdown peers if it's
|
||||||
|
// a super-bucket child. This keeps reordering scoped to where the
|
||||||
|
// user actually sees the bucket in the UI.
|
||||||
|
const siblings = state.buckets
|
||||||
|
.filter(b => (b.parent_id || null) === (bucket.parent_id || null))
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
const idx = siblings.findIndex(b => b.id == bucket.id);
|
||||||
const canLeft = idx > 0;
|
const canLeft = idx > 0;
|
||||||
const canRight = idx < state.buckets.length - 1;
|
const canRight = idx >= 0 && idx < siblings.length - 1;
|
||||||
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
|
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
|
||||||
|
|
||||||
|
const isCollab = !!bucket.collab_uid;
|
||||||
ctxMenu.innerHTML = `<div class="py-1">
|
ctxMenu.innerHTML = `<div class="py-1">
|
||||||
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">rename</span></button>
|
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Rename</span></button>
|
||||||
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
|
${isCollab && typeof window._collabBucketsEditDialog === 'function'
|
||||||
|
? `<button class="ctx-item" data-baction="collab-manage"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Members & owner…</span></button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
|
||||||
|
? `<button class="ctx-item" data-baction="collab-upgrade"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Make collaborative…</span></button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
||||||
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">move left</span></button>
|
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Move left</span></button>
|
||||||
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">move right</span></button>
|
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">Move right</span></button>
|
||||||
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
|
||||||
${hasCards
|
${hasCards
|
||||||
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
|
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete (keep cards)</span></button>
|
||||||
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>`
|
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete + cards</span></button>`
|
||||||
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`
|
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span></button>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
|
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
|
||||||
@@ -56,42 +73,96 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
document.getElementById('bctx-colors').classList.toggle('hidden');
|
document.getElementById('bctx-colors').classList.toggle('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
else if (a === 'collab-manage') {
|
||||||
|
hideBucketCtx();
|
||||||
|
if (typeof window._collabBucketsEditDialog === 'function') {
|
||||||
|
const changed = await window._collabBucketsEditDialog(bucket.collab_uid);
|
||||||
|
if (changed) await window._reloadAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (a === 'collab-upgrade') {
|
||||||
|
hideBucketCtx();
|
||||||
|
if (typeof window._collabBucketsUpgradeDialog === 'function') {
|
||||||
|
const changed = await window._collabBucketsUpgradeDialog(bucket.id);
|
||||||
|
if (changed) await window._reloadAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
else if (a === 'rename') {
|
else if (a === 'rename') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
|
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
// Collab buckets: push name change through the plugin (relay +
|
||||||
await loadBuckets(); window._renderBoard();
|
// mirror). The next poll pulls the new name into the local row.
|
||||||
|
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
|
||||||
|
const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() });
|
||||||
|
if (!res?.ok) { alert(res?.error || 'rename failed'); return; }
|
||||||
|
} else {
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'move-left' || a === 'move-right') {
|
else if (a === 'move-left' || a === 'move-right') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const swapIdx = a === 'move-left' ? idx - 1 : idx + 1;
|
const other = siblings[a === 'move-left' ? idx - 1 : idx + 1];
|
||||||
const other = state.buckets[swapIdx];
|
|
||||||
if (!other) return;
|
if (!other) return;
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } });
|
// Swap positions of two siblings — works for both top-level
|
||||||
await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } });
|
// and dropdown reordering since `siblings` is scoped above.
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: other.position ?? 0 } });
|
||||||
|
await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: bucket.position ?? 0 } });
|
||||||
await loadBuckets(); window._renderBoard();
|
await loadBuckets(); window._renderBoard();
|
||||||
}
|
}
|
||||||
else if (a === 'delete-keep') {
|
else if (a === 'delete-keep') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
const msg = bucket.collab_uid
|
||||||
|
? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`)
|
||||||
|
: (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`);
|
||||||
|
const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
// Collab bucket: route through plugin so the relay is notified
|
||||||
await loadBuckets(); await window._reloadAll();
|
// (owner = delete for everyone; member = just leave).
|
||||||
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
|
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
||||||
|
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
||||||
|
// Undo is intentionally NOT wired for collab deletes/leaves
|
||||||
|
// — restoring would require re-creating on the relay and
|
||||||
|
// re-attaching cards under a new uid. Too messy for MVP.
|
||||||
|
} else {
|
||||||
|
// Snapshot the row + affected card ids before deleting so
|
||||||
|
// the undo entry can restore them.
|
||||||
|
const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position };
|
||||||
|
const affected = cards.map(c => c.id);
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||||
|
pushUndo(`delete bucket "${bucket.name}"`, async () => {
|
||||||
|
const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } });
|
||||||
|
if (created?.id) {
|
||||||
|
await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } });
|
||||||
|
// Re-attach cards to the recreated bucket
|
||||||
|
await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'delete-all') {
|
else if (a === 'delete-all') {
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||||
const ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, danger: true, okLabel: 'delete all' });
|
const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
if (state.activeBucket == bucket.id) setActiveBucket('all');
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
|
||||||
await loadBuckets(); await window._reloadAll();
|
const res = await window._collabBucketsDelete(bucket.collab_uid);
|
||||||
|
if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
|
||||||
|
} else {
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -99,9 +170,14 @@ async function showBucketCtx(x, y, bucket) {
|
|||||||
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
|
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
hideBucketCtx();
|
hideBucketCtx();
|
||||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
|
const newColor = btn.dataset.bctxColor;
|
||||||
await loadBuckets();
|
if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
|
||||||
window._renderBoard();
|
const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor });
|
||||||
|
if (!res?.ok) { alert(res?.error || 'color change failed'); return; }
|
||||||
|
} else {
|
||||||
|
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } });
|
||||||
|
}
|
||||||
|
await window._reloadAll();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +186,61 @@ const inpBucket = $('inp-bucket');
|
|||||||
const searchFilter = $('search-bucket-filter');
|
const searchFilter = $('search-bucket-filter');
|
||||||
const tasksFilter = $('tasks-bucket-filter');
|
const tasksFilter = $('tasks-bucket-filter');
|
||||||
|
|
||||||
|
// ── Overflow handling for the bucket bar ─────────────────────────
|
||||||
|
// Scrollbar is hidden (CSS). Mouse-wheel over the bar translates to
|
||||||
|
// horizontal scroll so long bucket rows stay usable without a bar.
|
||||||
|
// Edge fades kick in dynamically based on scroll position so the
|
||||||
|
// user sees there's more off-screen — both sides at once in the
|
||||||
|
// middle, one side at either extreme. Fade overlays live on the
|
||||||
|
// parent #bucket-row (see CSS) so they don't clip super-bucket
|
||||||
|
// popovers that render inside #bucket-bar itself.
|
||||||
|
const barRow = $('bucket-row');
|
||||||
|
function updateBucketBarFades() {
|
||||||
|
if (!bar || !barRow) return;
|
||||||
|
const canLeft = bar.scrollLeft > 2;
|
||||||
|
const canRight = bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 2;
|
||||||
|
barRow.classList.toggle('fade-left', canLeft && !canRight);
|
||||||
|
barRow.classList.toggle('fade-right', !canLeft && canRight);
|
||||||
|
barRow.classList.toggle('fade-both', canLeft && canRight);
|
||||||
|
// Right-side fade overlay is anchored to the parent's right edge;
|
||||||
|
// the actual bar ends before the toolbar toggles. Compute the
|
||||||
|
// distance so the gradient lines up with the bar's right edge.
|
||||||
|
const rowRect = barRow.getBoundingClientRect();
|
||||||
|
const barRect = bar.getBoundingClientRect();
|
||||||
|
const rightInset = Math.max(0, rowRect.right - barRect.right);
|
||||||
|
barRow.style.setProperty('--bucket-row-right-inset', rightInset + 'px');
|
||||||
|
}
|
||||||
|
if (bar) {
|
||||||
|
bar.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (bar.scrollWidth <= bar.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
bar.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
bar.addEventListener('scroll', updateBucketBarFades, { passive: true });
|
||||||
|
window.addEventListener('resize', updateBucketBarFades);
|
||||||
|
new MutationObserver(updateBucketBarFades).observe(bar, { childList: true, subtree: false });
|
||||||
|
updateBucketBarFades();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch the active bucket without going through the click path —
|
||||||
|
* used by the number-key hotkeys (and any other code that wants to
|
||||||
|
* activate a bucket programmatically without triggering the super-
|
||||||
|
* bucket dropdown). Persists via setActiveBucket and re-renders the
|
||||||
|
* bar + board so the active highlight tracks. */
|
||||||
|
export function activateBucket(idOrSpecial) {
|
||||||
|
setActiveBucket(idOrSpecial);
|
||||||
|
state.activeGroup = null;
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadBuckets() {
|
export async function loadBuckets() {
|
||||||
state.buckets = await api('api/buckets');
|
state.buckets = await api('api/buckets');
|
||||||
|
// The persisted activeBucket may point to a bucket that no longer
|
||||||
|
// exists (deleted in another tab, removed via import, etc.). Reset
|
||||||
|
// to 'all' in that case before rendering.
|
||||||
|
ensureActiveBucketValid();
|
||||||
renderBar();
|
renderBar();
|
||||||
populateSelects();
|
populateSelects();
|
||||||
}
|
}
|
||||||
@@ -150,32 +279,245 @@ function updateHeaderTint() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remembers the last active member per super-bucket so the tile doesn't
|
||||||
|
// fall back to the parent label when the user navigates away and comes
|
||||||
|
// back. Keyed by parent bucket id → last selected member id (could be
|
||||||
|
// the parent itself or any child).
|
||||||
|
const lastSuperSelection = new Map();
|
||||||
|
|
||||||
function renderBar() {
|
function renderBar() {
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
|
const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`;
|
||||||
|
|
||||||
|
// Top-level buckets only — children are rendered inside their parent's
|
||||||
|
// dropdown popover, not directly in the bar.
|
||||||
|
const topLevel = state.buckets.filter(b => !b.parent_id);
|
||||||
|
const childrenOf = (parentId) => state.buckets
|
||||||
|
.filter(b => b.parent_id == parentId)
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
|
||||||
|
// Track which super-bucket member is active NOW so we can recall it
|
||||||
|
// later when the user comes back from a different bucket.
|
||||||
|
for (const b of topLevel) {
|
||||||
|
const kids = childrenOf(b.id);
|
||||||
|
if (!kids.length) continue;
|
||||||
|
if (state.activeBucket == b.id) lastSuperSelection.set(b.id, b.id);
|
||||||
|
const activeKid = kids.find(c => c.id == state.activeBucket);
|
||||||
|
if (activeKid) lastSuperSelection.set(b.id, activeKid.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve which child / parent to *show* on a super-bucket tile.
|
||||||
|
// Priority: currently active member → last selected member → parent.
|
||||||
|
const tileFor = (parent) => {
|
||||||
|
const kids = childrenOf(parent.id);
|
||||||
|
if (!kids.length) return null; // not a super-bucket
|
||||||
|
const activeChild = kids.find(c => c.id == state.activeBucket);
|
||||||
|
if (activeChild) return activeChild;
|
||||||
|
if (state.activeBucket == parent.id) return parent;
|
||||||
|
// Fall back to the last selection rather than the parent
|
||||||
|
const lastId = lastSuperSelection.get(parent.id);
|
||||||
|
if (lastId) {
|
||||||
|
if (lastId == parent.id) return parent;
|
||||||
|
const lastKid = kids.find(c => c.id == lastId);
|
||||||
|
if (lastKid) return lastKid;
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show an unread dot on the fixed `all` tab when state.unseenCardIds
|
||||||
|
// has anything in it AND the user isn't currently looking at the
|
||||||
|
// all view. board.js drives the unseen set; we just read it here so
|
||||||
|
// the dot survives bar re-renders that aren't followed by a board
|
||||||
|
// render (e.g. bucket reorder, plain renderBar() call).
|
||||||
|
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
|
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" 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>
|
||||||
${state.buckets.map(b => {
|
${topLevel.map(b => {
|
||||||
|
const kids = childrenOf(b.id);
|
||||||
|
const isSuper = kids.length > 0;
|
||||||
|
if (isSuper) {
|
||||||
|
// Super-bucket tile: shows the active member's name + a stack icon.
|
||||||
|
// The shown member's id goes on `data-super-shown` so we can
|
||||||
|
// highlight + recolor without taking over the dropdown click.
|
||||||
|
const shown = tileFor(b);
|
||||||
|
const c = shown.color || '#64748b';
|
||||||
|
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><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;
|
||||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top: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]">${esc(b.name)}</span></button>`;
|
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top: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]">${esc(b.name)}</span></button>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
|
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">Bucket</span></button>`;
|
||||||
|
|
||||||
updateHeaderTint();
|
updateHeaderTint();
|
||||||
|
|
||||||
let draggingBucketId = null;
|
let draggingBucketId = null;
|
||||||
|
|
||||||
bar.querySelectorAll('.bucket-tab').forEach(t => {
|
// Single insertion-cursor element shared by all bucket tabs. Lives
|
||||||
const bid = t.dataset.bucket;
|
// inside #bucket-bar (which is `position: relative` so absolute
|
||||||
const isUser = bid !== 'all' && bid !== 'none';
|
// children align to its scroll-aware coord space) and gets repositioned
|
||||||
|
// by the dragover handlers below. The cursor sits *between* tiles,
|
||||||
|
// not as a border on the target tile, so it stays unambiguous when
|
||||||
|
// we add the super-bucket nest gesture later.
|
||||||
|
let dropCursor = bar.querySelector('#bucket-drop-cursor');
|
||||||
|
if (!dropCursor) {
|
||||||
|
dropCursor = document.createElement('div');
|
||||||
|
dropCursor.id = 'bucket-drop-cursor';
|
||||||
|
bar.appendChild(dropCursor);
|
||||||
|
}
|
||||||
|
function showDropCursor(targetTab, before) {
|
||||||
|
// Position the cursor at the gap on the chosen side of the target.
|
||||||
|
// offsetLeft / offsetWidth are relative to the offsetParent
|
||||||
|
// (#bucket-bar) so they survive horizontal scroll.
|
||||||
|
const x = before
|
||||||
|
? targetTab.offsetLeft - 4
|
||||||
|
: targetTab.offsetLeft + targetTab.offsetWidth + 1;
|
||||||
|
dropCursor.style.left = x + 'px';
|
||||||
|
dropCursor.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideDropCursor() {
|
||||||
|
dropCursor.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
|
/** Can `draggedId` legally become a child of `targetId`?
|
||||||
|
* Rules: 1-level deep only, no self-parenting, target must be a real
|
||||||
|
* user bucket and currently top-level, dragged must not have children. */
|
||||||
|
function canNestInto(draggedId, targetId) {
|
||||||
|
if (!Number.isFinite(draggedId) || !Number.isFinite(targetId)) return false;
|
||||||
|
if (draggedId === targetId) return false;
|
||||||
|
const target = state.buckets.find(b => b.id === targetId);
|
||||||
|
if (!target || target.parent_id) return false;
|
||||||
|
const dragged = state.buckets.find(b => b.id === draggedId);
|
||||||
|
if (!dragged) return false;
|
||||||
|
// Already a child of the same target → no-op nest
|
||||||
|
if (dragged.parent_id === targetId) return false;
|
||||||
|
// Dragged has its own children → would create level-2
|
||||||
|
if (state.buckets.some(b => b.parent_id === draggedId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a popover under a super-bucket tile, listing parent + every
|
||||||
|
* child. Each entry is a real `.bucket-tab[data-bucket=...]` element
|
||||||
|
* so the collab plugin's MutationObserver still picks them up and
|
||||||
|
* decorates them with the unread dot / users-three icon. */
|
||||||
|
function openSuperDropdown(anchorTab, parentId) {
|
||||||
|
// Toggle: if a popover is already open for this parent, close it.
|
||||||
|
const existing = document.getElementById('bucket-super-popover');
|
||||||
|
if (existing) {
|
||||||
|
const wasFor = existing.dataset.parentId;
|
||||||
|
existing.remove();
|
||||||
|
if (wasFor === String(parentId)) return;
|
||||||
|
}
|
||||||
|
const parent = state.buckets.find(b => b.id === parentId);
|
||||||
|
if (!parent) return;
|
||||||
|
const kids = state.buckets
|
||||||
|
.filter(b => b.parent_id === parentId)
|
||||||
|
.sort((a, b) => (a.position || 0) - (b.position || 0));
|
||||||
|
const members = [parent, ...kids];
|
||||||
|
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.id = 'bucket-super-popover';
|
||||||
|
pop.dataset.parentId = String(parentId);
|
||||||
|
// Anchor under the tile, clamped to the viewport.
|
||||||
|
const rect = anchorTab.getBoundingClientRect();
|
||||||
|
pop.style.position = 'fixed';
|
||||||
|
pop.style.top = (rect.bottom + 4) + 'px';
|
||||||
|
pop.style.left = rect.left + 'px';
|
||||||
|
|
||||||
|
// Highlight the currently active member OR the last remembered
|
||||||
|
// selection (so the user sees which child was picked last time
|
||||||
|
// even when they're currently on a different bucket).
|
||||||
|
const lastId = lastSuperSelection.get(parentId);
|
||||||
|
const renderEntry = (m) => {
|
||||||
|
const c = m.color || '#64748b';
|
||||||
|
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
|
||||||
|
const isParent = m.id === parentId;
|
||||||
|
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" data-tip="parent"></i>' : ''}</button>`;
|
||||||
|
};
|
||||||
|
pop.innerHTML = members.map(renderEntry).join('');
|
||||||
|
// Append inside #bucket-bar (not document.body) so the collab
|
||||||
|
// plugin's MutationObserver on #bucket-bar still picks up these
|
||||||
|
// .bucket-tab entries and decorates collab children with the
|
||||||
|
// unread dot + users-three icon. Position is `fixed` anyway so
|
||||||
|
// the bar's overflow:auto can't clip the popover.
|
||||||
|
bar.appendChild(pop);
|
||||||
|
|
||||||
|
// Wire each entry: click selects, contextmenu opens bucket ctx,
|
||||||
|
// drag starts a re-parent / re-order operation against the bar.
|
||||||
|
pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => {
|
||||||
|
const ebid = entry.dataset.bucket;
|
||||||
|
entry.addEventListener('click', () => {
|
||||||
|
setActiveBucket(ebid);
|
||||||
|
state.activeGroup = null;
|
||||||
|
pop.remove();
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
});
|
||||||
|
entry.addEventListener('contextmenu', e => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const bk = state.buckets.find(b => b.id == ebid);
|
||||||
|
if (bk) showBucketCtx(e.clientX, e.clientY, bk);
|
||||||
|
});
|
||||||
|
entry.addEventListener('dragstart', e => {
|
||||||
|
draggingBucketId = ebid;
|
||||||
|
e.dataTransfer.setData('application/x-bucket', ebid);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
entry.style.opacity = '0.3';
|
||||||
|
});
|
||||||
|
entry.addEventListener('dragend', () => {
|
||||||
|
entry.style.opacity = '';
|
||||||
|
draggingBucketId = null;
|
||||||
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside the popover closes it.
|
||||||
|
setTimeout(() => {
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!pop.contains(e.target) && !anchorTab.contains(e.target)) {
|
||||||
|
pop.remove();
|
||||||
|
document.removeEventListener('click', onDocClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocClick);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.querySelectorAll('.bucket-tab').forEach(t => {
|
||||||
|
// Super-bucket tile uses `data-bucket-super`; regular tiles use
|
||||||
|
// `data-bucket`. The id we resolve here is the parent's id for a
|
||||||
|
// super-tile (so drag/contextmenu/etc. operate on the parent).
|
||||||
|
const isSuperTile = t.classList.contains('bucket-super');
|
||||||
|
const bid = isSuperTile ? t.dataset.bucketSuper : t.dataset.bucket;
|
||||||
|
const SPECIAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||||
|
const isUser = isSuperTile || !SPECIAL.includes(bid);
|
||||||
|
|
||||||
|
if (isSuperTile) {
|
||||||
|
// Caret arrow opens the dropdown; clicking the rest of the
|
||||||
|
// tab activates the last-shown child (or parent).
|
||||||
|
const caret = t.querySelector('[data-super-caret]');
|
||||||
|
if (caret) {
|
||||||
|
caret.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openSuperDropdown(t, parseInt(bid, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
t.addEventListener('click', () => {
|
||||||
|
const shownId = t.dataset.superShown;
|
||||||
|
setActiveBucket(shownId || bid);
|
||||||
|
state.activeGroup = null;
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
|
||||||
|
}
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
t.addEventListener('contextmenu', e => {
|
t.addEventListener('contextmenu', e => {
|
||||||
@@ -194,18 +536,31 @@ function renderBar() {
|
|||||||
t.addEventListener('dragend', () => {
|
t.addEventListener('dragend', () => {
|
||||||
t.style.opacity = '';
|
t.style.opacity = '';
|
||||||
draggingBucketId = null;
|
draggingBucketId = null;
|
||||||
bar.querySelectorAll('.bucket-drop-left,.bucket-drop-right').forEach(x => x.classList.remove('bucket-drop-left', 'bucket-drop-right'));
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addEventListener('dragover', e => {
|
t.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggingBucketId && isUser && draggingBucketId !== bid) {
|
if (draggingBucketId && isUser && draggingBucketId !== bid) {
|
||||||
// Bucket reorder indicator
|
// Three drop zones along the tile's width:
|
||||||
|
// left 25% → insert before
|
||||||
|
// right 25% → insert after
|
||||||
|
// center 50% → nest into this bucket
|
||||||
const rect = t.getBoundingClientRect();
|
const rect = t.getBoundingClientRect();
|
||||||
const left = e.clientX < rect.left + rect.width / 2;
|
const x = e.clientX - rect.left;
|
||||||
t.classList.toggle('bucket-drop-left', left);
|
const isLeft = x < rect.width * 0.25;
|
||||||
t.classList.toggle('bucket-drop-right', !left);
|
const isRight = x > rect.width * 0.75;
|
||||||
|
const isCenter = !isLeft && !isRight;
|
||||||
|
if (isCenter && canNestInto(parseInt(draggingBucketId, 10), parseInt(bid, 10))) {
|
||||||
|
hideDropCursor();
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
t.classList.add('bucket-nest-target');
|
||||||
|
} else {
|
||||||
|
bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target'));
|
||||||
|
showDropCursor(t, isLeft);
|
||||||
|
}
|
||||||
t.classList.remove('drag-over');
|
t.classList.remove('drag-over');
|
||||||
} else if (!draggingBucketId) {
|
} else if (!draggingBucketId) {
|
||||||
// Card drop on bucket
|
// Card drop on bucket
|
||||||
@@ -213,23 +568,44 @@ function renderBar() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
t.addEventListener('dragleave', () => {
|
t.addEventListener('dragleave', () => {
|
||||||
t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
|
t.classList.remove('drag-over');
|
||||||
});
|
});
|
||||||
t.addEventListener('drop', async e => {
|
t.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
|
t.classList.remove('drag-over', 'bucket-nest-target');
|
||||||
|
hideDropCursor();
|
||||||
|
|
||||||
// Bucket reorder
|
// Bucket reorder / nest
|
||||||
const dragBid = e.dataTransfer.getData('application/x-bucket');
|
const dragBid = e.dataTransfer.getData('application/x-bucket');
|
||||||
if (dragBid && isUser && dragBid !== bid) {
|
if (dragBid && isUser && dragBid !== bid) {
|
||||||
const rect = t.getBoundingClientRect();
|
const rect = t.getBoundingClientRect();
|
||||||
const before = e.clientX < rect.left + rect.width / 2;
|
const x = e.clientX - rect.left;
|
||||||
const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid);
|
const isLeft = x < rect.width * 0.25;
|
||||||
const targetIdx = ids.indexOf(String(bid));
|
const isRight = x > rect.width * 0.75;
|
||||||
ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid);
|
const isCenter = !isLeft && !isRight;
|
||||||
await Promise.all(ids.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } })));
|
if (isCenter && canNestInto(parseInt(dragBid, 10), parseInt(bid, 10))) {
|
||||||
await loadBuckets();
|
// Nest: dragged becomes a child of target. Backend
|
||||||
window._renderBoard();
|
// refuses cycles / level-2 with a 400 — bubble that
|
||||||
|
// up via alert so the user knows.
|
||||||
|
const res = await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: parseInt(bid, 10) } });
|
||||||
|
if (res?.error) { alert(res.error); return; }
|
||||||
|
await loadBuckets();
|
||||||
|
renderBar();
|
||||||
|
window._renderBoard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reorder among top-level buckets only.
|
||||||
|
const before = isLeft || (!isRight && x < rect.width / 2);
|
||||||
|
const topIds = state.buckets.filter(b => !b.parent_id).map(b => String(b.id)).filter(i => i !== dragBid);
|
||||||
|
const targetIdx = topIds.indexOf(String(bid));
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
topIds.splice(before ? targetIdx : targetIdx + 1, 0, dragBid);
|
||||||
|
// The dragged bucket also gets un-nested if it was a child.
|
||||||
|
await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: null } });
|
||||||
|
await Promise.all(topIds.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } })));
|
||||||
|
await loadBuckets();
|
||||||
|
window._renderBoard();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +622,12 @@ function renderBar() {
|
|||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid);
|
// Card drop on a super-bucket: card goes into the *currently
|
||||||
|
// shown* member of that group, not the parent itself.
|
||||||
|
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
||||||
|
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
||||||
if (bucketId === undefined) return;
|
if (bucketId === undefined) return;
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
||||||
window._clearSelection?.();
|
window._clearSelection?.();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
@@ -256,10 +636,23 @@ function renderBar() {
|
|||||||
|
|
||||||
const addBtn = $('add-bucket-btn');
|
const addBtn = $('add-bucket-btn');
|
||||||
addBtn?.addEventListener('click', async () => {
|
addBtn?.addEventListener('click', async () => {
|
||||||
|
// Plugins (e.g. collab-buckets) can override the default create
|
||||||
|
// dialog by exposing window._collabBucketsCreateDialog. It returns
|
||||||
|
// true when a bucket was created, in which case we reload.
|
||||||
|
if (typeof window._collabBucketsCreateDialog === 'function') {
|
||||||
|
const created = await window._collabBucketsCreateDialog();
|
||||||
|
if (created) { await loadBuckets(); window._renderBoard(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||||
await loadBuckets();
|
await loadBuckets();
|
||||||
|
if (created?.id) {
|
||||||
|
setActiveBucket(created.id);
|
||||||
|
state.activeGroup = null;
|
||||||
|
}
|
||||||
|
renderBar();
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,6 +673,7 @@ function renderBar() {
|
|||||||
const selected = window._getSelectedIds?.() || [];
|
const selected = window._getSelectedIds?.() || [];
|
||||||
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
||||||
const card = state.cards.find(c => String(c.id) === String(cardId));
|
const card = state.cards.find(c => String(c.id) === String(cardId));
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
||||||
if (!created?.id) return;
|
if (!created?.id) return;
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
||||||
|
|||||||
+34
-142
@@ -1,7 +1,13 @@
|
|||||||
// ── CalDAV Sync ──
|
// ── CalDAV Settings + Calendar Config ──
|
||||||
|
//
|
||||||
|
// Genuinely CalDAV-scoped concerns only:
|
||||||
|
// * Settings panel (connection URL / user / password, Test + Save)
|
||||||
|
// * Per-calendar color/icon configuration (persisted in localStorage)
|
||||||
|
//
|
||||||
|
// Sync orchestration (triggerSync, background poll, autosync toggle,
|
||||||
|
// sync-interval, relay settings, sync.tick hook) lives in sync.js.
|
||||||
|
|
||||||
import { $, esc, api, getCsrf } from './util.js';
|
import { $, esc, api, getCsrf } from './util.js';
|
||||||
import { state } from './state.js';
|
|
||||||
|
|
||||||
const CAL_ICONS = [
|
const CAL_ICONS = [
|
||||||
{ id: 'calendar-dots', label: 'calendar' },
|
{ id: 'calendar-dots', label: 'calendar' },
|
||||||
@@ -76,7 +82,7 @@ function renderCalList(calendars) {
|
|||||||
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 ml-1">
|
<div class="flex gap-1 ml-1">
|
||||||
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" data-tip="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -119,10 +125,31 @@ export async function loadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCaldavTestStatus(text, kind = 'info') {
|
||||||
|
if (!testStatus) return;
|
||||||
|
if (!text) {
|
||||||
|
testStatus.classList.add('hidden');
|
||||||
|
testStatus.textContent = '';
|
||||||
|
testStatus.style.borderColor = '';
|
||||||
|
testStatus.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testStatus.classList.remove('hidden');
|
||||||
|
testStatus.textContent = text;
|
||||||
|
if (kind === 'error') {
|
||||||
|
testStatus.style.borderColor = 'var(--t-danger)';
|
||||||
|
testStatus.style.color = 'var(--t-danger)';
|
||||||
|
} else if (kind === 'success') {
|
||||||
|
testStatus.style.borderColor = 'var(--t-accent)';
|
||||||
|
testStatus.style.color = 'var(--t-accent)';
|
||||||
|
} else {
|
||||||
|
testStatus.style.borderColor = 'var(--t-border)';
|
||||||
|
testStatus.style.color = 'var(--t-muted)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$('caldav-test')?.addEventListener('click', async () => {
|
$('caldav-test')?.addEventListener('click', async () => {
|
||||||
testStatus.textContent = 'testing...';
|
setCaldavTestStatus('testing...');
|
||||||
testStatus.style.color = 'var(--t-dim)';
|
|
||||||
const body = { url: urlInput.value, username: userInput.value };
|
const body = { url: urlInput.value, username: userInput.value };
|
||||||
if (passInput.value) body.password = passInput.value;
|
if (passInput.value) body.password = passInput.value;
|
||||||
const res = await fetch('api/caldav/test', {
|
const res = await fetch('api/caldav/test', {
|
||||||
@@ -132,12 +159,10 @@ $('caldav-test')?.addEventListener('click', async () => {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const names = (res.calendars || []).map(c => c.name).join(', ');
|
const names = (res.calendars || []).map(c => c.name).join(', ');
|
||||||
testStatus.textContent = `connected (${names || 'no calendars'})`;
|
setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success');
|
||||||
testStatus.style.color = 'var(--t-accent)';
|
|
||||||
if (res.calendars) renderCalList(res.calendars);
|
if (res.calendars) renderCalList(res.calendars);
|
||||||
} else {
|
} else {
|
||||||
testStatus.textContent = res.error || 'failed';
|
setCaldavTestStatus(res.error || 'failed', 'error');
|
||||||
testStatus.style.color = 'var(--t-danger)';
|
|
||||||
}
|
}
|
||||||
showConnectionLog(res.log);
|
showConnectionLog(res.log);
|
||||||
});
|
});
|
||||||
@@ -150,48 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
|
|||||||
testStatus.style.color = 'var(--t-accent)';
|
testStatus.style.color = 'var(--t-accent)';
|
||||||
});
|
});
|
||||||
|
|
||||||
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
|
||||||
|
|
||||||
export async function triggerSync() {
|
|
||||||
const btn = $('cal-sync');
|
|
||||||
if (btn) btn.style.opacity = '0.5';
|
|
||||||
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
|
||||||
|
|
||||||
const res = await fetch('api/caldav/sync', {
|
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
||||||
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
|
||||||
|
|
||||||
if (btn) btn.style.opacity = '';
|
|
||||||
|
|
||||||
// Also poll relay
|
|
||||||
const relayRes = await fetch('api/relay/poll', {
|
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
||||||
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const s = res.stats || {};
|
|
||||||
const rp = relayRes?.pulled || 0;
|
|
||||||
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
||||||
const allLog = res.log || [];
|
|
||||||
const hasErrors = allLog.some(l => l.status === 'error');
|
|
||||||
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
|
||||||
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
|
||||||
await window._reloadAll?.();
|
|
||||||
showLog(allLog);
|
|
||||||
showSyncNotification(total);
|
|
||||||
} else {
|
|
||||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLog(log) {
|
|
||||||
if (!logEl || !log?.length) return;
|
|
||||||
logEl.classList.remove('hidden');
|
|
||||||
logEl.innerHTML = log.map(l =>
|
|
||||||
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConnectionLog(lines) {
|
function showConnectionLog(lines) {
|
||||||
if (!logEl || !lines?.length) return;
|
if (!logEl || !lines?.length) return;
|
||||||
logEl.classList.remove('hidden');
|
logEl.classList.remove('hidden');
|
||||||
@@ -200,95 +183,4 @@ function showConnectionLog(lines) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Relay settings ──
|
|
||||||
|
|
||||||
$('relay-save')?.addEventListener('click', async () => {
|
|
||||||
const relayStatus = $('relay-status');
|
|
||||||
const body = { url: $('relay-url')?.value || '' };
|
|
||||||
const key = $('relay-key')?.value;
|
|
||||||
if (key) body.key = key;
|
|
||||||
await api('api/relay/settings', { method: 'POST', body });
|
|
||||||
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
|
|
||||||
});
|
|
||||||
|
|
||||||
$('relay-test')?.addEventListener('click', async () => {
|
|
||||||
const relayStatus = $('relay-status');
|
|
||||||
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
|
|
||||||
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
|
|
||||||
if (res?.ok) {
|
|
||||||
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
|
|
||||||
if (res.pulled) await window._reloadAll?.();
|
|
||||||
} else {
|
|
||||||
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function loadRelaySettings() {
|
|
||||||
const s = await api('api/relay/settings');
|
|
||||||
if (!s) return;
|
|
||||||
const urlInput = $('relay-url');
|
|
||||||
const keyInput = $('relay-key');
|
|
||||||
if (urlInput) urlInput.value = s.url || '';
|
|
||||||
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
|
|
||||||
}
|
|
||||||
|
|
||||||
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
||||||
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
|
||||||
|
|
||||||
// Auto-sync throttle
|
|
||||||
let lastAutoSync = 0;
|
|
||||||
export function autoSync() {
|
|
||||||
if (window._testRunning) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastAutoSync < 60000) return;
|
|
||||||
lastAutoSync = now;
|
|
||||||
triggerSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background sync every 2 minutes
|
|
||||||
function showSyncNotification(stats) {
|
|
||||||
const s = stats || {};
|
|
||||||
const parts = [];
|
|
||||||
if (s.pulled) parts.push(`${s.pulled} received`);
|
|
||||||
if (s.pushed) parts.push(`${s.pushed} sent`);
|
|
||||||
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
|
||||||
if (!parts.length) return;
|
|
||||||
|
|
||||||
const note = document.createElement('div');
|
|
||||||
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
|
||||||
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
|
||||||
document.body.appendChild(note);
|
|
||||||
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backgroundSync() {
|
|
||||||
if (state.bgSyncRunning) return;
|
|
||||||
state.bgSyncRunning = true;
|
|
||||||
try {
|
|
||||||
const headers = { 'X-CSRF-Token': getCsrf() };
|
|
||||||
const [res, relayRes] = await Promise.all([
|
|
||||||
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
||||||
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
||||||
]);
|
|
||||||
const s = res?.stats || {};
|
|
||||||
const rp = relayRes?.pulled || 0;
|
|
||||||
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
||||||
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
|
||||||
await window._reloadAll?.();
|
|
||||||
showSyncNotification(total);
|
|
||||||
}
|
|
||||||
} catch {} finally {
|
|
||||||
state.bgSyncRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background sync after app is ready
|
|
||||||
let bgSyncInterval = null;
|
|
||||||
export function startBackgroundSync() {
|
|
||||||
if (bgSyncInterval) return;
|
|
||||||
backgroundSync();
|
|
||||||
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
|
|
||||||
}
|
|
||||||
export function stopBackgroundSync() {
|
|
||||||
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { $, esc } from './util.js';
|
import { $, esc } from './util.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { stripMd, getTodos } from './markdown.js';
|
import { stripMd, getTodos } from './markdown.js';
|
||||||
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
|
import { autoSync } from './sync.js';
|
||||||
|
import { getCalConfig, getAllCalConfigs } from './caldav.js';
|
||||||
import { renderCardHtml } from './board.js';
|
import { renderCardHtml } from './board.js';
|
||||||
|
|
||||||
const overlay = $('calendar');
|
const overlay = $('calendar');
|
||||||
@@ -88,20 +89,35 @@ function matchesRRule(card, targetDateStr) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMultiDayEnd(card) {
|
||||||
|
const m = (card.body || '').match(/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
function getCardsForDate(ds) {
|
function getCardsForDate(ds) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const targetYear = parseInt(ds.substring(0, 4));
|
const targetYear = parseInt(ds.substring(0, 4));
|
||||||
for (const c of state.cards) {
|
for (const c of state.cards) {
|
||||||
if (cardBaseDate(c) === ds) {
|
const base = cardBaseDate(c);
|
||||||
|
if (base === ds) {
|
||||||
results.push({ ...c, _recurring: false });
|
results.push({ ...c, _recurring: false });
|
||||||
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
|
} else if (base && base < ds) {
|
||||||
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
|
// Multi-day event: check if ds is within [base..end]
|
||||||
|
const end = getMultiDayEnd(c);
|
||||||
|
if (end && ds <= end) {
|
||||||
|
const startMs = Date.parse(base);
|
||||||
|
const curMs = Date.parse(ds);
|
||||||
|
const dayNum = Math.round((curMs - startMs) / 86400000) + 1;
|
||||||
|
const totalDays = Math.round((Date.parse(end) - startMs) / 86400000) + 1;
|
||||||
|
results.push({ ...c, _recurring: false, _multiDay: { day: dayNum, total: totalDays, isEnd: ds === end } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.caldav_rrule && matchesRRule(c, ds)) {
|
||||||
|
const baseYear = parseInt(base.substring(0, 4));
|
||||||
const rrule = parseRRule(c.caldav_rrule);
|
const rrule = parseRRule(c.caldav_rrule);
|
||||||
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
||||||
results.push({ ...c, _recurring: true, _age: age });
|
if (base !== ds) results.push({ ...c, _recurring: true, _age: age });
|
||||||
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
||||||
// Local birthday cards repeat yearly without RRULE
|
|
||||||
const base = cardBaseDate(c);
|
|
||||||
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
||||||
const age = targetYear - parseInt(base.substring(0, 4));
|
const age = targetYear - parseInt(base.substring(0, 4));
|
||||||
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
||||||
@@ -146,7 +162,25 @@ function clearCalHover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
title.textContent = `${MONTHS[state.calMonth]} ${state.calYear}`;
|
// Format: [MM-DD] Mon Y [KW-KW]
|
||||||
|
// MM-DD = today's month-day
|
||||||
|
// Mon Y = displayed month + year (abbreviated)
|
||||||
|
// KW-KW = ISO week range spanning the displayed month
|
||||||
|
const isoWeek = (y, m, d) => {
|
||||||
|
const dt = new Date(Date.UTC(y, m, d));
|
||||||
|
const dayNum = dt.getUTCDay() || 7;
|
||||||
|
dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
|
||||||
|
};
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const nowDate = new Date();
|
||||||
|
const todayMd = `${pad(nowDate.getMonth() + 1)}-${pad(nowDate.getDate())}`;
|
||||||
|
const monAbbr = MONTHS[state.calMonth].substring(0, 3);
|
||||||
|
const lastDay = new Date(state.calYear, state.calMonth + 1, 0).getDate();
|
||||||
|
const wStart = isoWeek(state.calYear, state.calMonth, 1);
|
||||||
|
const wEnd = isoWeek(state.calYear, state.calMonth, lastDay);
|
||||||
|
title.innerHTML = `<span style="color:var(--t-dim)">[${todayMd}]</span> ${monAbbr} ${state.calYear} <span style="color:var(--t-dim)">[${pad(wStart)}-${pad(wEnd)}]</span>`;
|
||||||
|
|
||||||
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
||||||
const startOffset = (firstDay + 6) % 7; // Mon=0
|
const startOffset = (firstDay + 6) % 7; // Mon=0
|
||||||
@@ -210,9 +244,11 @@ function render() {
|
|||||||
const calCfg = getCardCalConfig(c);
|
const calCfg = getCardCalConfig(c);
|
||||||
const itemColor = calCfg?.color || c.color;
|
const itemColor = calCfg?.color || c.color;
|
||||||
const itemIcon = calCfg?.icon || null;
|
const itemIcon = calCfg?.icon || null;
|
||||||
|
const md = c._multiDay;
|
||||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
||||||
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
||||||
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
if (md) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:${itemColor};flex-shrink:0;">${md.day}/${md.total}</span>`;
|
||||||
|
else if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
||||||
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||||
html += `<span class="truncate">${esc(name)}</span>`;
|
html += `<span class="truncate">${esc(name)}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -398,7 +434,7 @@ function openDayPopup(ds, cellEl) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
list.innerHTML = '<div style="padding:16px 12px;text-align:center;font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">no cards</div>';
|
list.innerHTML = '<div class="cursor-blink" style="padding:16px 12px;text-align:center;font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">no cards</div>';
|
||||||
}
|
}
|
||||||
dayPopup.appendChild(list);
|
dayPopup.appendChild(list);
|
||||||
|
|
||||||
@@ -457,7 +493,7 @@ function openDayPopup(ds, cellEl) {
|
|||||||
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
||||||
const col = cfg?.color || '#fb923c';
|
const col = cfg?.color || '#fb923c';
|
||||||
const calTag = calSlug ? ` #${calSlug}` : '';
|
const calTag = calSlug ? ` #${calSlug}` : '';
|
||||||
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
|
const body = `**Zeit:** \n**Bis:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
|
||||||
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
|
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
|
||||||
} else if (type === 'birthday') {
|
} else if (type === 'birthday') {
|
||||||
const body = `#geburtstag`;
|
const body = `#geburtstag`;
|
||||||
@@ -494,9 +530,14 @@ export function refreshCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openCalendar() {
|
export function openCalendar() {
|
||||||
const now = new Date();
|
// Only jump to today on first open — preserve the user's navigation
|
||||||
state.calYear = now.getFullYear();
|
// when the calendar is re-opened (e.g. after saving a card in a
|
||||||
state.calMonth = now.getMonth();
|
// different month).
|
||||||
|
if (state.calYear == null || state.calMonth == null) {
|
||||||
|
const now = new Date();
|
||||||
|
state.calYear = now.getFullYear();
|
||||||
|
state.calMonth = now.getMonth();
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
overlay.style.visibility = '';
|
overlay.style.visibility = '';
|
||||||
@@ -506,6 +547,9 @@ export function openCalendar() {
|
|||||||
export function closeCalendar() {
|
export function closeCalendar() {
|
||||||
closeDayPopup();
|
closeDayPopup();
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('calendar-btn')?.addEventListener('click', openCalendar);
|
$('calendar-btn')?.addEventListener('click', openCalendar);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
|
import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js';
|
||||||
|
import { stripMd } from './markdown.js';
|
||||||
|
|
||||||
const menu = $('ctx-menu');
|
const menu = $('ctx-menu');
|
||||||
const items = $('ctx-items');
|
const items = $('ctx-items');
|
||||||
@@ -11,6 +12,12 @@ const colors = $('ctx-colors');
|
|||||||
const buckets = $('ctx-buckets');
|
const buckets = $('ctx-buckets');
|
||||||
const members = $('ctx-members');
|
const members = $('ctx-members');
|
||||||
const tags = $('ctx-tags');
|
const tags = $('ctx-tags');
|
||||||
|
const relayTenants = $('ctx-relay-tenants');
|
||||||
|
|
||||||
|
// Cached relay tenant list. Loaded on first push-relay submenu open
|
||||||
|
// for the session, then reused. Refreshed if the user closes/reopens
|
||||||
|
// the menu without success.
|
||||||
|
let cachedRelayTenants = null;
|
||||||
|
|
||||||
export function hideContextMenu() { menu.classList.add('hidden'); }
|
export function hideContextMenu() { menu.classList.add('hidden'); }
|
||||||
|
|
||||||
@@ -25,34 +32,127 @@ menu.addEventListener('mouseenter', () => {
|
|||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge every currently-selected card into a single card. Title strategy:
|
||||||
|
* if the selection involved a bulk gesture (rubber-band) we use the
|
||||||
|
* longest title — there's no meaningful "first" in that case. Otherwise
|
||||||
|
* the first ctrl-clicked card wins (state.selectedIds is a Set, which
|
||||||
|
* preserves insertion order).
|
||||||
|
*
|
||||||
|
* Body format: list of all titles as `# heading` lines at the top, then
|
||||||
|
* each card's body separated by `---`. The winner keeps its color, tags,
|
||||||
|
* bucket, due_date, parent_id; the others are deleted.
|
||||||
|
*
|
||||||
|
* The whole operation is bundled into a single undo entry that recreates
|
||||||
|
* the deleted cards verbatim and restores the winner's original body.
|
||||||
|
*/
|
||||||
|
export async function joinSelectedCards() {
|
||||||
|
const ids = [...state.selectedIds].map(String);
|
||||||
|
const cards = ids
|
||||||
|
.map(id => state.cards.find(c => String(c.id) === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (cards.length < 2) return;
|
||||||
|
|
||||||
|
// We need bodies for the merge. /api/cards GET excludes body for
|
||||||
|
// performance, so fetch the full rows for the selected ids in one shot.
|
||||||
|
const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json());
|
||||||
|
const fullById = new Map(fullRes.map(c => [String(c.id), c]));
|
||||||
|
const full = cards.map(c => fullById.get(String(c.id)) || c);
|
||||||
|
|
||||||
|
// Pick winner
|
||||||
|
let winner;
|
||||||
|
if (state.selectionWasBulk) {
|
||||||
|
winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a);
|
||||||
|
} else {
|
||||||
|
winner = full[0]; // first in Set iteration order = first selected
|
||||||
|
}
|
||||||
|
const losers = full.filter(c => c.id !== winner.id);
|
||||||
|
|
||||||
|
// Build merged body: title list, then bodies separated by `---`.
|
||||||
|
// Order: winner first (its body stays at the top after the title list),
|
||||||
|
// then losers in their selection order so the result is deterministic.
|
||||||
|
const ordered = [winner, ...losers];
|
||||||
|
const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n');
|
||||||
|
const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean);
|
||||||
|
let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
|
||||||
|
// Dedupe hashtag mentions across the merged body — first occurrence
|
||||||
|
// wins, subsequent ones get stripped (case-insensitive). Without
|
||||||
|
// this the merged card has every shared tag listed once per source
|
||||||
|
// card, which clutters the body and makes the rendered tag chips
|
||||||
|
// look duplicated to a casual reader (the server's extractTags also
|
||||||
|
// dedupes the tags column, but the body text itself is what the
|
||||||
|
// user actually edits).
|
||||||
|
const seenTags = new Set();
|
||||||
|
mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => {
|
||||||
|
const key = tag.toLowerCase();
|
||||||
|
if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace
|
||||||
|
seenTags.add(key);
|
||||||
|
return match;
|
||||||
|
}).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
// Snapshot for undo BEFORE we mutate anything
|
||||||
|
const winnerPrev = { title: winner.title, body: winner.body || '' };
|
||||||
|
const loserSnapshots = losers.map(c => snapshotCard(c));
|
||||||
|
|
||||||
|
// Apply: PATCH winner with the merged body, DELETE losers.
|
||||||
|
pushUndo(`join ${ordered.length} cards`, async () => {
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev });
|
||||||
|
for (const snap of loserSnapshots) {
|
||||||
|
const created = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: snap.title, body: snap.body, color: snap.color,
|
||||||
|
bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position,
|
||||||
|
}});
|
||||||
|
if (created?.id && snap.due_date) {
|
||||||
|
await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } });
|
||||||
|
for (const c of losers) {
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedIds.clear();
|
||||||
|
state.selectionWasBulk = false;
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
|
||||||
export function showContextMenu(x, y, card) {
|
export function showContextMenu(x, y, card) {
|
||||||
const children = getChildren(card.id);
|
const children = getChildren(card.id);
|
||||||
const isParent = children.length > 0;
|
const isParent = children.length > 0;
|
||||||
const isChild = !!card.parent_id;
|
const isChild = !!card.parent_id;
|
||||||
|
// Multi-select join: only offered when the right-clicked card is part
|
||||||
|
// of a selection of ≥2 cards. The action merges them into one.
|
||||||
|
const selected = state.selectedIds;
|
||||||
|
const inMultiSelect = selected.size >= 2 && selected.has(String(card.id));
|
||||||
|
|
||||||
items.innerHTML = `
|
items.innerHTML = `
|
||||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
|
${inMultiSelect ? `<button class="ctx-item" data-action="join"><i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join ${selected.size} cards</span></button><div class="ctx-sep"></div>` : ''}
|
||||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
|
||||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
|
||||||
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">move to date</span></button>` : ''}
|
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
|
||||||
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
|
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Move to date</span></button>` : ''}
|
||||||
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
|
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'Due: ' + card.due_date : 'Set due date'}</span></button>
|
||||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm ${parseTags(card).includes('done') ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${parseTags(card).includes('done') ? 'Unmark done' : 'Mark done'}</span></button>
|
||||||
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span></button>
|
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">Save .md</span></button>
|
||||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
<button class="ctx-item" data-action="copy-text"><i class="ph ph-clipboard-text text-sm"></i><span class="font-pixel text-[10px]">Copy text</span></button>
|
||||||
|
${typeof window._giteaMakeIssueFromCard === 'function' ? `<button class="ctx-item" data-action="make-ticket"><i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">Make ticket</span></button>` : ''}
|
||||||
|
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">Push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
|
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'Unfavorite' : 'Favorite'}</span></button>
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">Color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">Tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
${isParent ? `<div class="ctx-sep"></div>
|
${isParent ? `<div class="ctx-sep"></div>
|
||||||
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">open group</span></button>
|
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Open group</span></button>
|
||||||
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">Members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||||
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">dissolve group</span></button>` : ''}
|
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Dissolve group</span></button>` : ''}
|
||||||
${isChild ? `<div class="ctx-sep"></div>
|
${isChild ? `<div class="ctx-sep"></div>
|
||||||
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">go to group</span></button>
|
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Go to group</span></button>
|
||||||
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">ungroup</span></button>` : ''}
|
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Ungroup</span></button>` : ''}
|
||||||
<div class="ctx-sep"></div>
|
<div class="ctx-sep"></div>
|
||||||
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`;
|
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span></button>`;
|
||||||
|
|
||||||
// Color sub-panel
|
// Color sub-panel
|
||||||
const grid = $('ctx-color-grid');
|
const grid = $('ctx-color-grid');
|
||||||
@@ -71,7 +171,7 @@ export function showContextMenu(x, y, card) {
|
|||||||
members.classList.add('hidden');
|
members.classList.add('hidden');
|
||||||
|
|
||||||
// Bucket sub-panel
|
// Bucket sub-panel
|
||||||
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">no bucket</span></button>` +
|
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">No bucket</span></button>` +
|
||||||
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).join('');
|
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).join('');
|
||||||
buckets.classList.add('hidden');
|
buckets.classList.add('hidden');
|
||||||
|
|
||||||
@@ -91,40 +191,48 @@ export function showContextMenu(x, y, card) {
|
|||||||
<span class="font-pixel text-[10px]">#${esc(t)}</span>
|
<span class="font-pixel text-[10px]">#${esc(t)}</span>
|
||||||
</button>`).join('') +
|
</button>`).join('') +
|
||||||
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
|
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
|
||||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">add #${esc(f)}</span>
|
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">Add #${esc(f)}</span>
|
||||||
</button>` : '');
|
</button>` : '');
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
const targetId = card.id;
|
||||||
const tag = btn.dataset.ctxTag;
|
const tag = btn.dataset.ctxTag;
|
||||||
const has = cardTags.includes(tag);
|
// Re-read the current card from state to avoid stale body
|
||||||
let newBody = card.body || '';
|
const fresh = state.cards.find(x => x.id == targetId) || card;
|
||||||
|
const freshTags = parseTags(fresh);
|
||||||
|
const has = freshTags.includes(tag);
|
||||||
|
let newBody = fresh.body || '';
|
||||||
if (has) {
|
if (has) {
|
||||||
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
||||||
cardTags.splice(cardTags.indexOf(tag), 1);
|
|
||||||
} else {
|
} else {
|
||||||
newBody = newBody.trimEnd() + ' #' + tag;
|
newBody = newBody.trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
|
||||||
}
|
}
|
||||||
card.body = newBody;
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
// Trust the server's response for the new tags string
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
|
||||||
|
// Update our local working copy
|
||||||
|
card.body = c?.body ?? newBody;
|
||||||
|
cardTags.length = 0;
|
||||||
|
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
|
||||||
renderCtxTags(tagInput.value);
|
renderCtxTags(tagInput.value);
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation(); e.preventDefault();
|
||||||
|
const targetId = card.id;
|
||||||
const tag = btn.dataset.ctxTagNew;
|
const tag = btn.dataset.ctxTagNew;
|
||||||
const newBody = (card.body || '').trimEnd() + ' #' + tag;
|
const fresh = state.cards.find(x => x.id == targetId) || card;
|
||||||
card.body = newBody;
|
const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
allTags.push(tag);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
allTags.sort();
|
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
card.body = c?.body ?? newBody;
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
cardTags.length = 0;
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
|
||||||
|
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
|
||||||
tagInput.value = '';
|
tagInput.value = '';
|
||||||
renderCtxTags('');
|
renderCtxTags('');
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
@@ -162,12 +270,17 @@ export function showContextMenu(x, y, card) {
|
|||||||
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
|
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
|
||||||
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
|
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
|
||||||
|
|
||||||
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const a = btn.dataset.action;
|
const a = btn.dataset.action;
|
||||||
|
if (a === 'join') {
|
||||||
|
hideContextMenu();
|
||||||
|
await joinSelectedCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
|
||||||
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
|
||||||
else if (a === 'duplicate') {
|
else if (a === 'duplicate') {
|
||||||
@@ -175,19 +288,21 @@ export function showContextMenu(x, y, card) {
|
|||||||
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
|
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
|
||||||
if (hasDate) {
|
if (hasDate) {
|
||||||
const curDate = card.title.substring(0, 10);
|
const curDate = card.title.substring(0, 10);
|
||||||
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'duplicate' });
|
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' });
|
||||||
if (!newDate) return;
|
if (!newDate) return;
|
||||||
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||||
await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
const dup1 = await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
if (dup1?.id) state.recentlyCreatedIds?.add(String(dup1.id));
|
||||||
} else {
|
} else {
|
||||||
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
const dup2 = await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||||
|
if (dup2?.id) state.recentlyCreatedIds?.add(String(dup2.id));
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'move-date') {
|
else if (a === 'move-date') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
const curDate = card.title.substring(0, 10);
|
const curDate = card.title.substring(0, 10);
|
||||||
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'move' });
|
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'Move' });
|
||||||
if (!newDate || newDate === curDate) return;
|
if (!newDate || newDate === curDate) return;
|
||||||
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
|
||||||
@@ -196,29 +311,44 @@ export function showContextMenu(x, y, card) {
|
|||||||
}
|
}
|
||||||
else if (a === 'due-done') {
|
else if (a === 'due-done') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
// Clear due date and mark as done
|
const isDone = parseTags(card).includes('done');
|
||||||
const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done';
|
let newBody;
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } });
|
if (isDone) {
|
||||||
|
newBody = (card.body || '').replace(/(^|\s)#done\b/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n');
|
||||||
|
} else {
|
||||||
|
newBody = (card.body || '').trimEnd() + ((card.body || '').trimEnd() ? ' ' : '') + '#done';
|
||||||
|
}
|
||||||
|
const patch = { body: newBody };
|
||||||
|
if (!isDone && card.due_date) patch.due_date = null;
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: patch });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'due-date') {
|
else if (a === 'due-date') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'update' : 'set' });
|
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'Update' : 'Set' });
|
||||||
if (newDate === null) return;
|
if (newDate === null) return;
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}
|
}
|
||||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||||
else if (a === 'push-relay') {
|
else if (a === 'make-ticket') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
|
// Gitea plugin pre-fills the create-issue form from this
|
||||||
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
|
// card and leaves it open — the user reviews and submits.
|
||||||
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
await window._giteaMakeIssueFromCard?.(card);
|
||||||
const n = document.createElement('div');
|
}
|
||||||
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
|
else if (a === 'copy-text') {
|
||||||
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
|
hideContextMenu();
|
||||||
document.body.appendChild(n);
|
let body = card.body || '';
|
||||||
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
|
// Drop fenced code blocks (incl. slide-image), then markdown structure, then hashtags
|
||||||
|
body = body.replace(/```[\s\S]*?```/g, '');
|
||||||
|
body = stripMd(body);
|
||||||
|
body = body.replace(/(^|\s)#[\w/-]+/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{2,}/g, '\n\n').trim();
|
||||||
|
const text = (card.title || '') + '\n\n' + body;
|
||||||
|
try { await navigator.clipboard.writeText(text); } catch {}
|
||||||
|
}
|
||||||
|
else if (a === 'push-relay') {
|
||||||
|
await openRelayTenantSubmenu(card);
|
||||||
}
|
}
|
||||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||||
else if (a === 'color') toggle(colors);
|
else if (a === 'color') toggle(colors);
|
||||||
@@ -228,19 +358,57 @@ export function showContextMenu(x, y, card) {
|
|||||||
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
|
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
|
||||||
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
|
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
|
||||||
else if (a === 'dissolve') {
|
else if (a === 'dissolve') {
|
||||||
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen?`, danger: true, okLabel: 'dissolve' });
|
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'Dissolve' });
|
||||||
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
|
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
|
||||||
}
|
}
|
||||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||||
else if (a === 'delete') {
|
else if (a === 'delete') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
|
const children = getChildren(card.id);
|
||||||
|
const isGroup = children.length > 0;
|
||||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||||
if (needConfirm) {
|
if (isGroup) {
|
||||||
const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
// Always confirm when deleting a group — list every child
|
||||||
|
// so the user knows exactly what they're losing.
|
||||||
|
const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n');
|
||||||
|
const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`;
|
||||||
|
const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` });
|
||||||
|
if (!ok) return;
|
||||||
|
} else if (needConfirm) {
|
||||||
|
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
const wasSync = !!card.caldav_uid;
|
// Snapshot everything for undo BEFORE deleting
|
||||||
pushCardDelete('delete', snapshotCard(card));
|
const allCards = isGroup ? [card, ...children] : [card];
|
||||||
|
const snapshots = allCards.map(c => snapshotCard(c));
|
||||||
|
const wasSync = allCards.some(c => !!c.caldav_uid);
|
||||||
|
pushUndo(`delete ${isGroup ? 'group' : 'card'} "${card.title}"`, async () => {
|
||||||
|
// Recreate parent first so children can reference it
|
||||||
|
const parent = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: snapshots[0].title, body: snapshots[0].body,
|
||||||
|
color: snapshots[0].color, bucket_id: snapshots[0].bucket_id,
|
||||||
|
position: snapshots[0].position,
|
||||||
|
}});
|
||||||
|
if (parent?.id && snapshots[0].due_date) {
|
||||||
|
await api(`api/cards/${parent.id}`, { method: 'PATCH', body: { due_date: snapshots[0].due_date } });
|
||||||
|
}
|
||||||
|
// Recreate children pointing at the new parent
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
const s = snapshots[i];
|
||||||
|
const child = await api('api/cards', { method: 'POST', body: {
|
||||||
|
title: s.title, body: s.body, color: s.color,
|
||||||
|
bucket_id: s.bucket_id, parent_id: parent?.id,
|
||||||
|
position: s.position,
|
||||||
|
}});
|
||||||
|
if (child?.id && s.due_date) {
|
||||||
|
await api(`api/cards/${child.id}`, { method: 'PATCH', body: { due_date: s.due_date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Animate out, then delete children first, then parent
|
||||||
|
const { animateCardRemoval } = await import('./board.js');
|
||||||
|
await animateCardRemoval(allCards.map(c => c.id));
|
||||||
|
for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||||
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
if (wasSync) window._triggerSync?.();
|
if (wasSync) window._triggerSync?.();
|
||||||
@@ -248,7 +416,97 @@ export function showContextMenu(x, y, card) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
||||||
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); }));
|
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation(); hideContextMenu();
|
||||||
|
pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id });
|
||||||
|
window._markCardsMoving?.([card.id]);
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}));
|
||||||
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
||||||
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Push to relay submenu (cross-tenant recipient picker) ──
|
||||||
|
|
||||||
|
async function openRelayTenantSubmenu(card) {
|
||||||
|
// Show submenu immediately with a loading state, then fetch the
|
||||||
|
// tenant list (cached after first successful load).
|
||||||
|
const toggle = (panel) => {
|
||||||
|
[colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); });
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
toggle(relayTenants);
|
||||||
|
|
||||||
|
if (!cachedRelayTenants) {
|
||||||
|
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel">loading tenants...</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/relay/tenants').then(r => r.json());
|
||||||
|
if (!res.ok) {
|
||||||
|
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${res.error || 'failed'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cachedRelayTenants = res;
|
||||||
|
} catch (err) {
|
||||||
|
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${err.message}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRelayTenants(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRelayTenants(card) {
|
||||||
|
const list = cachedRelayTenants?.tenants || [];
|
||||||
|
const self = cachedRelayTenants?.self || '';
|
||||||
|
if (!list.length) {
|
||||||
|
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic cursor-blink">no tenants</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Self first, then others alphabetical
|
||||||
|
const ordered = [
|
||||||
|
...list.filter(t => t.username === self),
|
||||||
|
...list.filter(t => t.username !== self),
|
||||||
|
];
|
||||||
|
relayTenants.innerHTML = ordered.map(t => {
|
||||||
|
const isSelf = t.username === self;
|
||||||
|
const icon = isSelf ? 'ph-user' : 'ph-user-circle';
|
||||||
|
const label = isSelf ? `${t.username} (you)` : t.username;
|
||||||
|
return `<button class="ctx-item" data-relay-recipient="${t.username}"><i class="ph ${icon} text-sm"></i><span class="font-pixel text-[10px]">${label}</span></button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const recipient = btn.dataset.relayRecipient;
|
||||||
|
hideContextMenu();
|
||||||
|
const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
|
||||||
|
relayPushToast(res, recipient);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayPushToast(res, recipient) {
|
||||||
|
const ok = res?.ok;
|
||||||
|
const msg = ok
|
||||||
|
? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
|
||||||
|
: (res?.error || 'push failed');
|
||||||
|
const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
||||||
|
if (!ok) {
|
||||||
|
// Dump every diagnostic the backend gave us so we can see the wire-level
|
||||||
|
// truth instead of guessing why the relay said no
|
||||||
|
console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
|
||||||
|
console.log('error:', res?.error);
|
||||||
|
if (res?.relay_url) console.log('url:', res.relay_url);
|
||||||
|
if (res?.sent_username) console.log('sent username:', res.sent_username);
|
||||||
|
if (res?.response_body) console.log('response body:', res.response_body);
|
||||||
|
if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
|
||||||
|
const hint = ok ? '' : '<div style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);margin-top:4px;">see browser console for wire-level debug</div>';
|
||||||
|
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${esc(msg)}</div>${hint}`;
|
||||||
|
document.body.appendChild(n);
|
||||||
|
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
|
||||||
|
}
|
||||||
+165
-9
@@ -5,6 +5,7 @@ import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, m
|
|||||||
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
import { activateBucket } from './buckets.js';
|
||||||
|
|
||||||
const detail = $('detail');
|
const detail = $('detail');
|
||||||
const scroll = $('detail-scroll');
|
const scroll = $('detail-scroll');
|
||||||
@@ -57,6 +58,18 @@ export function openDetail(id, options = {}) {
|
|||||||
if (!card) return;
|
if (!card) return;
|
||||||
markRecent(id);
|
markRecent(id);
|
||||||
|
|
||||||
|
// Optional: switch the board to the card's bucket so the user
|
||||||
|
// sees context. Only when opened via search, wikilink, or
|
||||||
|
// programmatic call — not when the user clicked a board card
|
||||||
|
// (they're already in the right bucket). Gated by a setting
|
||||||
|
// under Behavior → Interactions.
|
||||||
|
if (card.bucket_id
|
||||||
|
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
|
||||||
|
&& state.activeBucket !== 'all'
|
||||||
|
&& String(state.activeBucket) !== String(card.bucket_id)) {
|
||||||
|
activateBucket(card.bucket_id);
|
||||||
|
}
|
||||||
|
|
||||||
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
||||||
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
||||||
|
|
||||||
@@ -72,9 +85,104 @@ export function openDetail(id, options = {}) {
|
|||||||
|
|
||||||
renderSidebar(card);
|
renderSidebar(card);
|
||||||
showContent(card);
|
showContent(card);
|
||||||
|
dismissStaleBanner();
|
||||||
detail.classList.add('open');
|
detail.classList.add('open');
|
||||||
|
|
||||||
|
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remote-update detection ──
|
||||||
|
//
|
||||||
|
// When a foreign edit arrives for the card the user is currently viewing,
|
||||||
|
// the pull replaces state.cards but the open detail view keeps rendering
|
||||||
|
// the stale object it captured on open. Instead of force-re-rendering
|
||||||
|
// under the user's finger (jarring when they're mid-read), surface a
|
||||||
|
// banner with a manual Reload button.
|
||||||
|
|
||||||
|
let staleBannerEl = null;
|
||||||
|
|
||||||
|
function dismissStaleBanner() {
|
||||||
|
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStaleBanner(fresh) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
const host = detail.querySelector('#detail-controls')?.parentElement;
|
||||||
|
if (!host) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'detail-stale-banner';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<i class="ph ph-info"></i>
|
||||||
|
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
||||||
|
<button class="detail-stale-reload">
|
||||||
|
<i class="ph ph-arrows-clockwise text-xs"></i>
|
||||||
|
<span>Reload</span>
|
||||||
|
</button>
|
||||||
|
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
`;
|
||||||
|
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
||||||
|
staleBannerEl = bar;
|
||||||
|
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
||||||
|
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
||||||
|
if (current) {
|
||||||
|
state.viewingCard = current;
|
||||||
|
renderSidebar(current);
|
||||||
|
showContent(current);
|
||||||
|
// Treat the reload as an explicit "I've now seen the new version"
|
||||||
|
// — re-fire detail.opened so collab-buckets clears the red dot
|
||||||
|
// on the board card and persists mark-card-seen to the backend.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
|
||||||
|
}
|
||||||
|
dismissStaleBanner();
|
||||||
|
});
|
||||||
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
|
||||||
|
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
|
||||||
|
// local time — off by the tz offset (2h on CEST, hence "2h ago"
|
||||||
|
// for a 30-second-old update).
|
||||||
|
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
|
||||||
|
? iso
|
||||||
|
: iso.replace(' ', 'T') + 'Z';
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
if (!isFinite(t)) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After every full card reload, check whether the card we're viewing is
|
||||||
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||||
|
// detail view closed in the meantime.
|
||||||
|
function checkStaleViewingCard() {
|
||||||
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewingId = state.viewingCard.id;
|
||||||
|
const fresh = state.cards.find(c => c.id == viewingId);
|
||||||
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||||
|
const old = state.viewingCard;
|
||||||
|
// Compare updated_at OR any field that might have changed. The detail
|
||||||
|
// view captures viewingCard by reference on open, so a replaced
|
||||||
|
// state.cards array (via _reloadAll) gives us a different object — if
|
||||||
|
// any visible field differs, surface the banner.
|
||||||
|
const changed =
|
||||||
|
(fresh.updated_at || '') > (old.updated_at || '') ||
|
||||||
|
(fresh.title || '') !== (old.title || '') ||
|
||||||
|
(fresh.body || '') !== (old.body || '') ||
|
||||||
|
(fresh.color || '') !== (old.color || '') ||
|
||||||
|
(fresh.tags || '') !== (old.tags || '');
|
||||||
|
if (changed) showStaleBanner(fresh);
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard);
|
||||||
|
|
||||||
function clearMorphGhost() {
|
function clearMorphGhost() {
|
||||||
if (morphTimer) {
|
if (morphTimer) {
|
||||||
clearTimeout(morphTimer);
|
clearTimeout(morphTimer);
|
||||||
@@ -370,6 +478,7 @@ export function closeDetail(options = {}) {
|
|||||||
closeImageFullscreen();
|
closeImageFullscreen();
|
||||||
|
|
||||||
detail.classList.remove('open', 'zen');
|
detail.classList.remove('open', 'zen');
|
||||||
|
dismissStaleBanner();
|
||||||
removeZenHandles();
|
removeZenHandles();
|
||||||
document.documentElement.classList.remove('detail-zen-open');
|
document.documentElement.classList.remove('detail-zen-open');
|
||||||
document.body.classList.remove('detail-zen-open');
|
document.body.classList.remove('detail-zen-open');
|
||||||
@@ -391,6 +500,11 @@ export function closeDetail(options = {}) {
|
|||||||
state._returnToCalendar = false;
|
state._returnToCalendar = false;
|
||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
|
// Return focus to body so board shortcuts work immediately.
|
||||||
|
// Skipped when we just focused the search input (openedFromSearch).
|
||||||
|
if (!state.openedFromSearch && document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDetailCard(id) {
|
export function showDetailCard(id) {
|
||||||
@@ -417,6 +531,7 @@ function renderSidebar(card) {
|
|||||||
|
|
||||||
if (groupCards && groupCards.length > 1) {
|
if (groupCards && groupCards.length > 1) {
|
||||||
sidebar.classList.remove('hidden');
|
sidebar.classList.remove('hidden');
|
||||||
|
sidebar.dataset.groupId = String(groupCards[0].id);
|
||||||
$('detail-sidebar-count').textContent = groupCards.length;
|
$('detail-sidebar-count').textContent = groupCards.length;
|
||||||
list.innerHTML = groupCards.map(gc => {
|
list.innerHTML = groupCards.map(gc => {
|
||||||
const active = gc.id == card.id;
|
const active = gc.id == card.id;
|
||||||
@@ -427,7 +542,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>`;
|
||||||
@@ -438,6 +553,7 @@ function renderSidebar(card) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
sidebar.classList.add('hidden');
|
sidebar.classList.add('hidden');
|
||||||
|
delete sidebar.dataset.groupId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,13 +567,53 @@ function updateDoneButton(card) {
|
|||||||
btn.classList.toggle('text-t-accent', isDone);
|
btn.classList.toggle('text-t-accent', isDone);
|
||||||
btn.classList.toggle('border-t-accent', isDone);
|
btn.classList.toggle('border-t-accent', isDone);
|
||||||
}
|
}
|
||||||
if (label) label.textContent = isDone ? 'undone' : 'done';
|
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReadButton(card) {
|
||||||
|
const isOn = !!Number(card?.read_push || 0);
|
||||||
|
const btn = $('detail-read-toggle');
|
||||||
|
const icon = $('detail-read-icon');
|
||||||
|
const label = $('detail-read-label');
|
||||||
|
if (!btn) return;
|
||||||
|
// Off state reuses the neutral control-bar style; on state inverts
|
||||||
|
// (accent-filled tile with dark text) so "this card sits on the relay
|
||||||
|
// right now" is visually unmistakable, matching typical toggle UIs.
|
||||||
|
btn.classList.toggle('bg-t-accent', isOn);
|
||||||
|
btn.classList.toggle('border-t-accent', isOn);
|
||||||
|
btn.classList.toggle('text-t-bg', isOn);
|
||||||
|
btn.classList.toggle('text-t-muted', !isOn);
|
||||||
|
btn.classList.toggle('hover:border-t-accent', !isOn);
|
||||||
|
btn.classList.toggle('hover:text-t-accent', !isOn);
|
||||||
|
if (icon) icon.className = (isOn ? 'ph-fill ph-book-open' : 'ph ph-book-open') + ' text-sm';
|
||||||
|
if (label) label.textContent = isOn ? 'On relay' : 'Read';
|
||||||
|
btn.title = isOn
|
||||||
|
? 'On relay — click to remove from your Read list'
|
||||||
|
: 'Push this card to your relay\'s Read list for distraction-free mobile reading';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('detail-read-toggle')?.addEventListener('click', async () => {
|
||||||
|
const card = state.viewingCard;
|
||||||
|
if (!card) return;
|
||||||
|
const next = Number(card.read_push || 0) ? 0 : 1;
|
||||||
|
try {
|
||||||
|
const updated = await api(`api/cards/${card.id}`, { method: 'PATCH', body: { read_push: next } });
|
||||||
|
if (updated && !updated.error) {
|
||||||
|
card.read_push = updated.read_push;
|
||||||
|
card.relay_uid = updated.relay_uid;
|
||||||
|
const c = state.cards.find(x => x.id == card.id);
|
||||||
|
if (c) { c.read_push = updated.read_push; c.relay_uid = updated.relay_uid; }
|
||||||
|
updateReadButton(card);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
} catch (_) { /* network error: silently ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
function showContent(card) {
|
function showContent(card) {
|
||||||
$('detail-color').style.background = card.color;
|
$('detail-color').style.background = card.color;
|
||||||
titleEl.textContent = card.title;
|
titleEl.textContent = card.title;
|
||||||
updateDoneButton(card);
|
updateDoneButton(card);
|
||||||
|
updateReadButton(card);
|
||||||
const doneCorner = $('detail-done-corner');
|
const doneCorner = $('detail-done-corner');
|
||||||
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
||||||
bodyEl.innerHTML = renderMd(card.body);
|
bodyEl.innerHTML = renderMd(card.body);
|
||||||
@@ -547,7 +703,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);
|
||||||
|
|
||||||
@@ -608,7 +764,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();
|
||||||
@@ -805,7 +961,7 @@ function renderSplitGroup(cards, label) {
|
|||||||
|
|
||||||
function renderSplitPicks(cards, label) {
|
function renderSplitPicks(cards, label) {
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
splitPicks.innerHTML = renderSplitGroup(cards, label);
|
||||||
@@ -817,7 +973,7 @@ function renderSplitPicks(cards, label) {
|
|||||||
function renderSplitGroups(groups) {
|
function renderSplitGroups(groups) {
|
||||||
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
const html = groups.filter(g => g.cards.length).map(g => renderSplitGroup(g.cards, g.label)).join('');
|
||||||
if (!html) {
|
if (!html) {
|
||||||
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px]">no matches</div>`;
|
splitPicks.innerHTML = `<div class="px-3 py-4 text-center text-t-dim font-pixel text-[9px] cursor-blink">no matches</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
splitPicks.innerHTML = html;
|
splitPicks.innerHTML = html;
|
||||||
@@ -891,8 +1047,8 @@ 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>`;
|
||||||
splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList);
|
splitPicks.querySelector('#split-back').addEventListener('click', restoreSplitList);
|
||||||
@@ -1015,7 +1171,7 @@ $('detail-done')?.addEventListener('click', async () => {
|
|||||||
$('detail-delete')?.addEventListener('click', async () => {
|
$('detail-delete')?.addEventListener('click', async () => {
|
||||||
if (!state.viewingCard) return;
|
if (!state.viewingCard) return;
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
const ok = await showDialog({ title: 'delete_card', message: `"${state.viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
|
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${state.viewingCard.title}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function renderDatepicker() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
|
export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'Ok', cancelLabel = 'Cancel' }) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
resolve = res;
|
resolve = res;
|
||||||
titleEl.textContent = title;
|
titleEl.textContent = title;
|
||||||
@@ -150,6 +150,11 @@ export function showDialog({ title, message = '', input = false, placeholder = '
|
|||||||
|
|
||||||
export function isDialogOpen() { return overlay.classList.contains('open'); }
|
export function isDialogOpen() { return overlay.classList.contains('open'); }
|
||||||
|
|
||||||
|
// Expose to plugins via the hyprcrd API
|
||||||
|
if (typeof window !== 'undefined' && window.hyprcrd?.dialog) {
|
||||||
|
window.hyprcrd.dialog.show = showDialog;
|
||||||
|
}
|
||||||
|
|
||||||
okBtn.addEventListener('click', submit);
|
okBtn.addEventListener('click', submit);
|
||||||
cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||||
if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||||
|
|||||||
+460
-79
@@ -4,6 +4,7 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
|
|||||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const modal = $('modal');
|
const modal = $('modal');
|
||||||
const body = $('editor-body');
|
const body = $('editor-body');
|
||||||
@@ -13,6 +14,8 @@ const inpTitle = $('inp-title');
|
|||||||
const inpColor = $('inp-color');
|
const inpColor = $('inp-color');
|
||||||
const inpBucket = $('inp-bucket');
|
const inpBucket = $('inp-bucket');
|
||||||
const presets = $('color-presets');
|
const presets = $('color-presets');
|
||||||
|
const scratchpadPanel = $('editor-scratchpad');
|
||||||
|
const scratchpadBody = $('scratchpad-body');
|
||||||
const linkAc = $('link-ac');
|
const linkAc = $('link-ac');
|
||||||
const tagAc = $('tag-ac');
|
const tagAc = $('tag-ac');
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ function startCloseMorph() {
|
|||||||
|
|
||||||
CARD_COLORS.forEach(c => {
|
CARD_COLORS.forEach(c => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button'; btn.title = c.name;
|
btn.type = 'button'; btn.dataset.tip = c.name;
|
||||||
btn.dataset.color = c.hex;
|
btn.dataset.color = c.hex;
|
||||||
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
|
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
|
||||||
btn.style.background = c.hex;
|
btn.style.background = c.hex;
|
||||||
@@ -116,7 +119,7 @@ const inpDueBtn = $('inp-due-date');
|
|||||||
|
|
||||||
function setDueDate(date) {
|
function setDueDate(date) {
|
||||||
if (inpDueValue) inpDueValue.value = date || '';
|
if (inpDueValue) inpDueValue.value = date || '';
|
||||||
if (inpDueLabel) inpDueLabel.textContent = date || 'no due';
|
if (inpDueLabel) inpDueLabel.textContent = date || 'No due';
|
||||||
if (inpDueBtn) {
|
if (inpDueBtn) {
|
||||||
inpDueBtn.classList.toggle('text-t-accent', !!date);
|
inpDueBtn.classList.toggle('text-t-accent', !!date);
|
||||||
inpDueBtn.classList.toggle('border-t-accent', !!date);
|
inpDueBtn.classList.toggle('border-t-accent', !!date);
|
||||||
@@ -131,7 +134,7 @@ inpDueBtn?.addEventListener('click', async () => {
|
|||||||
title: current ? 'change due date' : 'set due date',
|
title: current ? 'change due date' : 'set due date',
|
||||||
input: 'date',
|
input: 'date',
|
||||||
defaultValue: current || new Date().toISOString().substring(0, 10),
|
defaultValue: current || new Date().toISOString().substring(0, 10),
|
||||||
okLabel: current ? 'update' : 'set',
|
okLabel: current ? 'Update' : 'Set',
|
||||||
});
|
});
|
||||||
if (result === null) return;
|
if (result === null) return;
|
||||||
setDueDate(result || '');
|
setDueDate(result || '');
|
||||||
@@ -147,7 +150,7 @@ function updateDoneToggle() {
|
|||||||
inpDoneToggle.classList.toggle('border-t-accent', isDone);
|
inpDoneToggle.classList.toggle('border-t-accent', isDone);
|
||||||
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
|
inpDoneToggle.classList.toggle('text-t-dim', !isDone);
|
||||||
}
|
}
|
||||||
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'undone' : 'done';
|
if (inpDoneLabel) inpDoneLabel.textContent = isDone ? 'Undone' : 'Done';
|
||||||
}
|
}
|
||||||
|
|
||||||
inpDoneToggle?.addEventListener('click', () => {
|
inpDoneToggle?.addEventListener('click', () => {
|
||||||
@@ -176,6 +179,45 @@ function selectColor(hex) {
|
|||||||
|
|
||||||
// ── Open / close ──
|
// ── Open / close ──
|
||||||
|
|
||||||
|
// ── Autobackup (draft recovery) ──
|
||||||
|
const DRAFT_KEY = 'hyprcrd-editor-draft';
|
||||||
|
let draftSaveTimer = null;
|
||||||
|
|
||||||
|
function draftKeyFor(cardId) {
|
||||||
|
return DRAFT_KEY + ':' + (cardId || 'new');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDraft(cardId) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftKeyFor(cardId));
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraftDebounced() {
|
||||||
|
clearTimeout(draftSaveTimer);
|
||||||
|
draftSaveTimer = setTimeout(() => {
|
||||||
|
const id = state.editingId || 'new';
|
||||||
|
const data = {
|
||||||
|
title: inpTitle.value,
|
||||||
|
body: body.value,
|
||||||
|
scratchpad: scratchpadBody?.value || '',
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
// Don't store empty drafts
|
||||||
|
if (!data.title.trim() && !data.body.trim() && !data.scratchpad.trim()) {
|
||||||
|
localStorage.removeItem(draftKeyFor(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(draftKeyFor(id), JSON.stringify(data)); } catch {}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraft(cardId) {
|
||||||
|
clearTimeout(draftSaveTimer);
|
||||||
|
try { localStorage.removeItem(draftKeyFor(cardId || 'new')); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
export function openModal(card = null) {
|
export function openModal(card = null) {
|
||||||
state.editingId = card ? card.id : null;
|
state.editingId = card ? card.id : null;
|
||||||
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
|
||||||
@@ -184,28 +226,74 @@ export function openModal(card = null) {
|
|||||||
inpTitle.value = card ? card.title : (isJournal ? today : '');
|
inpTitle.value = card ? card.title : (isJournal ? today : '');
|
||||||
body.value = card ? card.body : '';
|
body.value = card ? card.body : '';
|
||||||
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
||||||
|
// New appointments (#termin / #geburtstag pre-filled from the
|
||||||
|
// calendar's day-popup template) must never inherit the active
|
||||||
|
// bucket — they belong to a calendar, not a kanban column. For
|
||||||
|
// every other new card, inherit the active real bucket if we're
|
||||||
|
// in one so the user doesn't have to re-pick it.
|
||||||
|
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
|
||||||
|
// from the calendar day-popup — both count as "new", so key on
|
||||||
|
// card?.id not the card ref itself.
|
||||||
|
const isNewAppointment = !card?.id && /#(termin|appointment|geburtstag|birthday)\b/.test(body.value);
|
||||||
inpBucket.value = card?.bucket_id ? card.bucket_id
|
inpBucket.value = card?.bucket_id ? card.bucket_id
|
||||||
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
|
: (isNewAppointment ? ''
|
||||||
|
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
|
||||||
selectColor(card ? card.color : '#38bdf8');
|
selectColor(card ? card.color : '#38bdf8');
|
||||||
setDueDate(card?.due_date || '');
|
setDueDate(card?.due_date || '');
|
||||||
updateDoneToggle();
|
updateDoneToggle();
|
||||||
state.previewVisible = true;
|
if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
|
||||||
preview.classList.remove('hidden');
|
showSidePanel('preview');
|
||||||
editorDivider?.classList.remove('hidden');
|
|
||||||
$('toggle-preview').classList.add('text-t-accent');
|
|
||||||
applyEditorSplit();
|
|
||||||
updatePreview();
|
|
||||||
modal.classList.add('open');
|
modal.classList.add('open');
|
||||||
if (linesVisible) updateGutter();
|
if (linesVisible) updateGutter();
|
||||||
currentLine = -1;
|
currentLine = -1;
|
||||||
updateActiveLine();
|
updateActiveLine();
|
||||||
|
|
||||||
|
// Check for unsaved draft (newer than what we just loaded).
|
||||||
|
// Skipped during test runs — tests would block on the dialog.
|
||||||
|
const draft = loadDraft(card?.id);
|
||||||
|
if (draft && !window._testRunning) {
|
||||||
|
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
||||||
|
const draftIsNewer = draft.savedAt > cardModified;
|
||||||
|
const draftHasContent = draft.title || draft.body || draft.scratchpad;
|
||||||
|
const differsFromLoaded = draft.title !== inpTitle.value || draft.body !== body.value || draft.scratchpad !== (scratchpadBody?.value || '');
|
||||||
|
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
||||||
|
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
||||||
|
const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
|
||||||
|
const cardId = card?.id;
|
||||||
|
// Async confirm — fires after openModal returns
|
||||||
|
(async () => {
|
||||||
|
const restore = await showDialog({
|
||||||
|
title: 'unsaved draft',
|
||||||
|
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
||||||
|
okLabel: 'Restore',
|
||||||
|
cancelLabel: 'Discard',
|
||||||
|
});
|
||||||
|
if (!modal.classList.contains('open') || state.editingId != cardId) return;
|
||||||
|
if (restore) {
|
||||||
|
inpTitle.value = draft.title || '';
|
||||||
|
body.value = draft.body || '';
|
||||||
|
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
||||||
|
updatePreview();
|
||||||
|
} else {
|
||||||
|
clearDraft(cardId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
} else if (draft && window._testRunning) {
|
||||||
|
clearDraft(card?.id);
|
||||||
|
}
|
||||||
|
|
||||||
inpTitle.focus();
|
inpTitle.focus();
|
||||||
|
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
||||||
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
inpTitle.addEventListener('input', updatePreview);
|
inpTitle.addEventListener('input', () => { updatePreview(); saveDraftDebounced(); });
|
||||||
inpTitle.addEventListener('keydown', e => {
|
inpTitle.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
|
||||||
});
|
});
|
||||||
|
body.addEventListener('input', saveDraftDebounced);
|
||||||
|
scratchpadBody?.addEventListener('input', saveDraftDebounced);
|
||||||
|
|
||||||
// ── Line numbers ──
|
// ── Line numbers ──
|
||||||
|
|
||||||
@@ -232,7 +320,8 @@ function getMirror() {
|
|||||||
mirror.style.paddingRight = cs.paddingRight;
|
mirror.style.paddingRight = cs.paddingRight;
|
||||||
mirror.style.borderLeft = cs.borderLeft;
|
mirror.style.borderLeft = cs.borderLeft;
|
||||||
mirror.style.borderRight = cs.borderRight;
|
mirror.style.borderRight = cs.borderRight;
|
||||||
mirror.style.width = body.offsetWidth + 'px';
|
// Use clientWidth to exclude scrollbar, matching the actual text flow area
|
||||||
|
mirror.style.width = body.clientWidth + 'px';
|
||||||
return mirror;
|
return mirror;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +349,16 @@ function updateGutter() {
|
|||||||
sentinel.textContent = '\u200b';
|
sentinel.textContent = '\u200b';
|
||||||
m.appendChild(sentinel);
|
m.appendChild(sentinel);
|
||||||
|
|
||||||
|
// Use the first short line's height as the base line height
|
||||||
|
const baseH = divs[0] ? (divs.length > 1 ? divs[1].offsetTop - divs[0].offsetTop : sentinel.offsetTop - divs[0].offsetTop) : 22;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (let i = 0; i < divs.length; i++) {
|
for (let i = 0; i < divs.length; i++) {
|
||||||
const top = divs[i].offsetTop;
|
const top = divs[i].offsetTop;
|
||||||
const nextTop = (i + 1 < divs.length) ? divs[i + 1].offsetTop : sentinel.offsetTop;
|
const nextTop = (i + 1 < divs.length) ? divs[i + 1].offsetTop : sentinel.offsetTop;
|
||||||
const h = nextTop - top;
|
const h = nextTop - top;
|
||||||
html += `<div style="height:${h}px" class="${i + 1 === active ? 'gutter-active' : ''}">${i + 1}</div>`;
|
// Outer div: exact height, no line-height influence. Inner span: base line-height so number sits on first line.
|
||||||
|
html += `<div style="display:block;height:${h}px;line-height:0;overflow:hidden" class="${i + 1 === active ? 'gutter-active' : ''}"><span style="display:inline-block;line-height:${baseH}px">${i + 1}</span></div>`;
|
||||||
}
|
}
|
||||||
gutter.innerHTML = html;
|
gutter.innerHTML = html;
|
||||||
currentLine = active;
|
currentLine = active;
|
||||||
@@ -349,11 +442,14 @@ body.addEventListener('keyup', updateActiveLine);
|
|||||||
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
||||||
|
|
||||||
export function closeModal({ animate = true } = {}) {
|
export function closeModal({ animate = true } = {}) {
|
||||||
|
const closingId = state.editingId;
|
||||||
if (animate) startCloseMorph();
|
if (animate) startCloseMorph();
|
||||||
else clearMorphGhost();
|
else clearMorphGhost();
|
||||||
hideLinkAc();
|
hideLinkAc();
|
||||||
hideTagAc();
|
hideTagAc();
|
||||||
hideSnippetAc();
|
hideSnippetAc();
|
||||||
|
closeFindBar();
|
||||||
|
closeAssetPicker();
|
||||||
modal.classList.remove('open');
|
modal.classList.remove('open');
|
||||||
state.editingId = null;
|
state.editingId = null;
|
||||||
state.returnToGalleryAfterEdit = false;
|
state.returnToGalleryAfterEdit = false;
|
||||||
@@ -361,6 +457,12 @@ export function closeModal({ animate = true } = {}) {
|
|||||||
state._returnToCalendar = false;
|
state._returnToCalendar = false;
|
||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
|
||||||
|
// Return focus to body so board shortcuts (arrows, bare keys) work
|
||||||
|
// immediately after closing without the user having to click first.
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -380,13 +482,16 @@ function updatePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(close, { animate = true } = {}) {
|
async function doSave(close, { animate = true } = {}) {
|
||||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null };
|
const scratch = scratchpadBody?.value || null;
|
||||||
|
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch };
|
||||||
if (!data.title.trim()) return;
|
if (!data.title.trim()) return;
|
||||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||||
|
const wasEditingId = state.editingId;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
||||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
|
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
|
||||||
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||||
|
clearDraft(state.editingId);
|
||||||
} else {
|
} else {
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
@@ -401,11 +506,13 @@ async function doSave(close, { animate = true } = {}) {
|
|||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
if (!close) state.editingId = created.id;
|
if (!close) state.editingId = created.id;
|
||||||
state._lastSavedId = created.id;
|
state._lastSavedId = created.id;
|
||||||
|
state.recentlyCreatedIds?.add(String(created.id));
|
||||||
}
|
}
|
||||||
|
clearDraft(null); // 'new' draft
|
||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
// Auto-sync if card has #termin or #geburtstag
|
// Auto-sync if card has #termin or #geburtstag
|
||||||
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
|
if (data.body && /#(termin|appointment|geburtstag|birthday)\b/.test(data.body)) {
|
||||||
window._triggerSync?.();
|
window._triggerSync?.();
|
||||||
}
|
}
|
||||||
if (close) {
|
if (close) {
|
||||||
@@ -436,56 +543,72 @@ function insertMd(before, after = '', placeholder = '') {
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Built-in editor commands ──
|
||||||
|
// All commands flow through the plugin command registry. Plugins can
|
||||||
|
// register their own data-md="..." commands via window.hyprcrd.editor.
|
||||||
|
const _hyprEd = (typeof window !== 'undefined' && window.hyprcrd?.editor) || null;
|
||||||
|
const _registerCmd = (name, fn) => {
|
||||||
|
if (_hyprEd) _hyprEd.registerCommand(name, fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
_registerCmd('heading', () => insertMd('## ', '', 'Heading'));
|
||||||
|
_registerCmd('bold', () => insertMd('**', '**', 'bold'));
|
||||||
|
_registerCmd('italic', () => insertMd('*', '*', 'italic'));
|
||||||
|
_registerCmd('strikethrough', () => insertMd('~~', '~~', 'text'));
|
||||||
|
_registerCmd('code', () => insertMd('`', '`', 'code'));
|
||||||
|
_registerCmd('codeblock', () => insertMd('```\n', '\n```', 'code'));
|
||||||
|
_registerCmd('ul', () => insertMd('- ', '', 'item'));
|
||||||
|
_registerCmd('ol', () => insertMd('1. ', '', 'item'));
|
||||||
|
_registerCmd('tree', () => insertMd('```tree\n- ', '\n - child\n', 'parent'));
|
||||||
|
_registerCmd('quote', () => insertMd('> ', '', 'quote'));
|
||||||
|
_registerCmd('todo', () => {
|
||||||
|
const s = body.selectionStart, e = body.selectionEnd;
|
||||||
|
const sel = body.value.substring(s, e);
|
||||||
|
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||||
|
const lines = sel.split('\n');
|
||||||
|
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||||
|
const converted = lines.map(l => {
|
||||||
|
const trimmed = l.trim();
|
||||||
|
if (!trimmed) return l;
|
||||||
|
if (hasTodos) return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||||
|
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||||
|
}).join('\n');
|
||||||
|
body.setRangeText(converted, s, e, 'select');
|
||||||
|
body.selectionStart = s;
|
||||||
|
body.selectionEnd = s + converted.length;
|
||||||
|
updatePreview();
|
||||||
|
} else {
|
||||||
|
insertMd('- [ ] ', '', 'task');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_registerCmd('link', () => insertMd('[', '](url)', 'text'));
|
||||||
|
_registerCmd('cardlink', () => { insertMd('[[', ']]', ''); showLinkAc(''); });
|
||||||
|
_registerCmd('extract', async () => {
|
||||||
|
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
||||||
|
if (!sel.trim()) return;
|
||||||
|
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
||||||
|
await window._extractToCard(sel);
|
||||||
|
const link = `[[${title}]]`;
|
||||||
|
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
_registerCmd('hr', () => insertMd('\n---\n'));
|
||||||
|
|
||||||
|
// Expose editor API to plugins (textarea, insertMd, preview refresh)
|
||||||
|
if (_hyprEd) {
|
||||||
|
_hyprEd.api = {
|
||||||
|
body,
|
||||||
|
insertMd,
|
||||||
|
updatePreview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar dispatch routes through the plugin registry
|
||||||
$('md-toolbar')?.addEventListener('click', e => {
|
$('md-toolbar')?.addEventListener('click', e => {
|
||||||
const btn = e.target.closest('[data-md]');
|
const btn = e.target.closest('[data-md]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const cmds = {
|
const fn = _hyprEd?.getCommand(btn.dataset.md);
|
||||||
heading: () => insertMd('## ', '', 'Heading'), bold: () => insertMd('**', '**', 'bold'),
|
if (fn) fn();
|
||||||
italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'),
|
|
||||||
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
|
|
||||||
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
|
|
||||||
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
|
|
||||||
quote: () => insertMd('> ', '', 'quote'),
|
|
||||||
todo: () => {
|
|
||||||
const s = body.selectionStart, e = body.selectionEnd;
|
|
||||||
const sel = body.value.substring(s, e);
|
|
||||||
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
|
||||||
// Multi-line or bullet list: convert each line
|
|
||||||
const lines = sel.split('\n');
|
|
||||||
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
|
||||||
const converted = lines.map(l => {
|
|
||||||
const trimmed = l.trim();
|
|
||||||
if (!trimmed) return l;
|
|
||||||
if (hasTodos) {
|
|
||||||
// Toggle back: remove checkbox
|
|
||||||
return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
|
||||||
}
|
|
||||||
// Add checkbox: strip existing bullet if present
|
|
||||||
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
|
||||||
}).join('\n');
|
|
||||||
body.setRangeText(converted, s, e, 'select');
|
|
||||||
body.selectionStart = s;
|
|
||||||
body.selectionEnd = s + converted.length;
|
|
||||||
updatePreview();
|
|
||||||
} else {
|
|
||||||
insertMd('- [ ] ', '', 'task');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
link: () => insertMd('[', '](url)', 'text'),
|
|
||||||
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
|
|
||||||
extract: async () => {
|
|
||||||
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
|
||||||
if (!sel.trim()) return;
|
|
||||||
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
|
||||||
await window._extractToCard(sel);
|
|
||||||
// Replace selection with a card link
|
|
||||||
const link = `[[${title}]]`;
|
|
||||||
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
|
||||||
updatePreview();
|
|
||||||
},
|
|
||||||
hr: () => insertMd('\n---\n'),
|
|
||||||
};
|
|
||||||
cmds[btn.dataset.md]?.();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorDivider = $('editor-divider');
|
const editorDivider = $('editor-divider');
|
||||||
@@ -496,22 +619,47 @@ const editorPane = $('editor-pane');
|
|||||||
|
|
||||||
function applyEditorSplit() {
|
function applyEditorSplit() {
|
||||||
if (!state.previewVisible) return;
|
if (!state.previewVisible) return;
|
||||||
if (editorPane) editorPane.style.flex = `0 0 ${editorSplit}%`;
|
// Use grow-based splits with flex-basis:0 so both panes share the available
|
||||||
preview.style.flex = `0 0 ${100 - editorSplit}%`;
|
// space AFTER the divider is subtracted. Using `0 0 X%` overflows the container
|
||||||
|
// because X% + (100-X)% + divider > 100%, which clips content on the right.
|
||||||
|
if (editorPane) editorPane.style.flex = `${editorSplit} 1 0`;
|
||||||
|
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('toggle-preview')?.addEventListener('click', () => {
|
function showSidePanel(which) {
|
||||||
state.previewVisible = !state.previewVisible;
|
const showPreview = which === 'preview';
|
||||||
preview.classList.toggle('hidden', !state.previewVisible);
|
const showScratch = which === 'scratchpad';
|
||||||
editorDivider?.classList.toggle('hidden', !state.previewVisible);
|
|
||||||
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
|
state.previewVisible = showPreview;
|
||||||
if (state.previewVisible) {
|
preview.classList.toggle('hidden', !showPreview);
|
||||||
|
scratchpadPanel?.classList.toggle('hidden', !showScratch);
|
||||||
|
editorDivider?.classList.toggle('hidden', !showPreview && !showScratch);
|
||||||
|
$('toggle-preview')?.classList.toggle('text-t-accent', showPreview);
|
||||||
|
$('toggle-scratchpad')?.classList.toggle('text-t-accent', showScratch);
|
||||||
|
|
||||||
|
if (showPreview || showScratch) {
|
||||||
applyEditorSplit();
|
applyEditorSplit();
|
||||||
|
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
|
||||||
} else {
|
} else {
|
||||||
if (editorPane) editorPane.style.flex = '1 1 0%';
|
if (editorPane) editorPane.style.flex = '1 1 0%';
|
||||||
preview.style.flex = '';
|
preview.style.flex = '';
|
||||||
|
if (scratchpadPanel) scratchpadPanel.style.flex = '';
|
||||||
}
|
}
|
||||||
updatePreview();
|
if (showPreview) updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('toggle-preview')?.addEventListener('click', () => {
|
||||||
|
showSidePanel(state.previewVisible ? null : 'preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('toggle-scratchpad')?.addEventListener('click', () => {
|
||||||
|
const isOpen = !scratchpadPanel?.classList.contains('hidden');
|
||||||
|
showSidePanel(isOpen ? null : 'scratchpad');
|
||||||
|
if (!isOpen) scratchpadBody?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('scratchpad-clear')?.addEventListener('click', () => {
|
||||||
|
if (scratchpadBody) scratchpadBody.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (editorDivider) {
|
if (editorDivider) {
|
||||||
@@ -955,15 +1103,133 @@ body.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
||||||
|
|
||||||
|
// ── Find / Replace (Ctrl+H / Ctrl+F) ──
|
||||||
|
|
||||||
|
const findBar = $('editor-find-bar');
|
||||||
|
const findInput = $('find-input');
|
||||||
|
const replInput = $('replace-input');
|
||||||
|
const findCount = $('find-count');
|
||||||
|
const replRow = $('replace-row');
|
||||||
|
|
||||||
|
let findMatches = [];
|
||||||
|
let findCurrent = -1;
|
||||||
|
|
||||||
|
export function openFindBar(withReplace = true) {
|
||||||
|
if (!findBar) return;
|
||||||
|
findBar.classList.remove('hidden');
|
||||||
|
replRow?.classList.toggle('hidden', !withReplace);
|
||||||
|
findInput?.focus();
|
||||||
|
if (findInput?.value) runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFindBar() {
|
||||||
|
findBar?.classList.add('hidden');
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (findCount) findCount.textContent = '';
|
||||||
|
body.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFind() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (!needle) { if (findCount) findCount.textContent = ''; return; }
|
||||||
|
const text = body.value;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = lower.indexOf(nLower, pos);
|
||||||
|
if (idx === -1) break;
|
||||||
|
findMatches.push(idx);
|
||||||
|
pos = idx + 1;
|
||||||
|
}
|
||||||
|
if (findCount) findCount.textContent = findMatches.length ? `${findMatches.length} found` : 'no match';
|
||||||
|
if (findMatches.length) jumpTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpTo(idx) {
|
||||||
|
findCurrent = idx;
|
||||||
|
const pos = findMatches[idx];
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
body.focus();
|
||||||
|
body.setSelectionRange(pos, pos + needle.length);
|
||||||
|
if (findCount) findCount.textContent = `${idx + 1} / ${findMatches.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent + 1) % findMatches.length);
|
||||||
|
}
|
||||||
|
function findPrev() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent - 1 + findMatches.length) % findMatches.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOne() {
|
||||||
|
if (findCurrent < 0 || !findMatches.length) return;
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
const pos = findMatches[findCurrent];
|
||||||
|
body.setRangeText(repl, pos, pos + needle.length, 'end');
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
if (findMatches.length && findCurrent >= findMatches.length) findCurrent = 0;
|
||||||
|
if (findMatches.length) jumpTo(findCurrent < 0 ? 0 : findCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
if (!needle) return;
|
||||||
|
// Simple case-insensitive global replace via regex
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
body.value = body.value.replace(new RegExp(escaped, 'gi'), repl);
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
findInput?.addEventListener('input', runFind);
|
||||||
|
findInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
replInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
$('find-next')?.addEventListener('click', findNext);
|
||||||
|
$('find-prev')?.addEventListener('click', findPrev);
|
||||||
|
$('find-close')?.addEventListener('click', closeFindBar);
|
||||||
|
$('replace-one')?.addEventListener('click', replaceOne);
|
||||||
|
$('replace-all')?.addEventListener('click', replaceAll);
|
||||||
|
|
||||||
|
// Ctrl+H = find+replace, Ctrl+F = find only (within editor)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (!modal.classList.contains('open')) return;
|
||||||
|
if ((e.key === 'h' || e.key === 'f') && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// If already open and same mode, toggle off
|
||||||
|
if (!findBar?.classList.contains('hidden')) {
|
||||||
|
const wantReplace = e.key === 'h';
|
||||||
|
const hasReplace = !replRow?.classList.contains('hidden');
|
||||||
|
if (wantReplace === hasReplace) { closeFindBar(); return; }
|
||||||
|
}
|
||||||
|
openFindBar(e.key === 'h');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── File upload (paste + drag-drop) ──
|
// ── File upload (paste + drag-drop) ──
|
||||||
|
|
||||||
async function uploadFile(file) {
|
async function uploadFile(file) {
|
||||||
const isAudio = file.type.startsWith('audio/');
|
const isAudio = file.type.startsWith('audio/');
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
if (!isAudio && !isImage) return;
|
const isPdf = file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
|
||||||
|
if (!isAudio && !isImage && !isPdf) return;
|
||||||
|
|
||||||
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
|
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
|
||||||
const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
|
const placeholder = isPdf ? '[uploading pdf...]' : isAudio ? '[uploading audio...]' : '![uploading...]()';
|
||||||
const pos = body.selectionStart;
|
const pos = body.selectionStart;
|
||||||
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
|
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
|
||||||
updatePreview();
|
updatePreview();
|
||||||
@@ -976,9 +1242,11 @@ async function uploadFile(file) {
|
|||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
const md = isAudio
|
const md = isPdf
|
||||||
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
? `[📄 ${file.name || data.name}](${data.url})`
|
||||||
: ``;
|
: isAudio
|
||||||
|
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
||||||
|
: ``;
|
||||||
const idx = body.value.indexOf(placeholder);
|
const idx = body.value.indexOf(placeholder);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length);
|
body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length);
|
||||||
@@ -999,7 +1267,7 @@ body.addEventListener('paste', e => {
|
|||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith('image/') || item.type.startsWith('audio/')) {
|
if (item.type.startsWith('image/') || item.type.startsWith('audio/') || item.type === 'application/pdf') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) uploadFile(file);
|
if (file) uploadFile(file);
|
||||||
@@ -1013,7 +1281,7 @@ body.addEventListener('drop', e => {
|
|||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.type.startsWith('image/') || file.type.startsWith('audio/')) {
|
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type === 'application/pdf') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
uploadFile(file);
|
uploadFile(file);
|
||||||
return;
|
return;
|
||||||
@@ -1021,9 +1289,122 @@ body.addEventListener('drop', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PDF upload toolbar button
|
||||||
|
$('md-upload-pdf')?.addEventListener('click', () => $('md-upload-pdf-file')?.click());
|
||||||
|
$('md-upload-pdf-file')?.addEventListener('change', e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) uploadFile(file);
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Asset picker (insert from uploads) ──
|
||||||
|
|
||||||
|
const ASSET_IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
const ASSET_AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
||||||
|
const ASSET_PDF_EXT = /\.pdf$/i;
|
||||||
|
|
||||||
|
function assetType(name) {
|
||||||
|
if (ASSET_IMAGE_EXT.test(name)) return 'image';
|
||||||
|
if (ASSET_AUDIO_EXT.test(name)) return 'audio';
|
||||||
|
if (ASSET_PDF_EXT.test(name)) return 'pdf';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
let assetPickerEl = null;
|
||||||
|
|
||||||
|
function closeAssetPicker() {
|
||||||
|
if (assetPickerEl) { assetPickerEl.remove(); assetPickerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('md-asset-picker')?.addEventListener('click', async () => {
|
||||||
|
if (assetPickerEl) { closeAssetPicker(); return; }
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
try { files = await api('api/uploads'); } catch {}
|
||||||
|
if (!Array.isArray(files)) files = [];
|
||||||
|
|
||||||
|
const btn = $('md-asset-picker');
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.id = 'asset-picker-flyout';
|
||||||
|
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;width:340px;max-height:400px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:200;display:flex;flex-direction:column;overflow:hidden;`;
|
||||||
|
|
||||||
|
const searchHtml = `<div style="padding:8px;border-bottom:1px solid var(--t-border);">
|
||||||
|
<input id="asset-picker-search" type="text" placeholder="search assets..." autocomplete="off"
|
||||||
|
style="width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:5px 8px;font-size:11px;font-family:var(--t-font-content,'JetBrains Mono',monospace);border-radius:4px;outline:none;"
|
||||||
|
class="focus:border-t-accent">
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const gridId = 'asset-picker-grid';
|
||||||
|
picker.innerHTML = searchHtml + `<div id="${gridId}" style="flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:6px;"></div>`;
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
assetPickerEl = picker;
|
||||||
|
|
||||||
|
const grid = picker.querySelector(`#${gridId}`);
|
||||||
|
const searchInput = picker.querySelector('#asset-picker-search');
|
||||||
|
|
||||||
|
function renderTiles(filter) {
|
||||||
|
const q = (filter || '').toLowerCase();
|
||||||
|
const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q)) : files;
|
||||||
|
if (!filtered.length) {
|
||||||
|
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:24px 0;color:var(--t-dim);font-size:10px;font-style:italic;">${files.length ? 'no matches' : 'no assets'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = filtered.map(f => {
|
||||||
|
const type = assetType(f.name);
|
||||||
|
const label = f.name.replace(/\.[^.]+$/, '');
|
||||||
|
if (type === 'image') {
|
||||||
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="image" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);">
|
||||||
|
<img src="${esc(f.url)}" style="width:100%;height:100%;object-fit:cover;" loading="lazy">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const icon = type === 'audio' ? 'ph-music-note' : type === 'pdf' ? 'ph-file-pdf' : 'ph-file';
|
||||||
|
const iconColor = type === 'pdf' ? 'color:var(--t-danger)' : 'color:var(--t-dim)';
|
||||||
|
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="${type}" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px;">
|
||||||
|
<i class="ph ${icon}" style="font-size:20px;${iconColor}"></i>
|
||||||
|
<span style="font-size:7px;color:var(--t-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--t-font-pixel,'Silkscreen',monospace);">${esc(label)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
grid.querySelectorAll('.asset-pick-tile').forEach(tile => {
|
||||||
|
tile.addEventListener('click', () => {
|
||||||
|
const url = tile.dataset.url;
|
||||||
|
const name = tile.dataset.name;
|
||||||
|
const type = tile.dataset.type;
|
||||||
|
let md;
|
||||||
|
if (type === 'image') md = `![${name.replace(/\.[^.]+$/, '')}](${url})`;
|
||||||
|
else if (type === 'pdf') md = `[📄 ${name}](${url})`;
|
||||||
|
else if (type === 'audio') md = `<audio controls src="${url}"></audio>`;
|
||||||
|
else md = `[${name}](${url})`;
|
||||||
|
insertMd(md);
|
||||||
|
closeAssetPicker();
|
||||||
|
});
|
||||||
|
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--t-accent)'; });
|
||||||
|
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--t-border)'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTiles('');
|
||||||
|
setTimeout(() => searchInput.focus(), 0);
|
||||||
|
searchInput.addEventListener('input', () => renderTiles(searchInput.value));
|
||||||
|
searchInput.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAssetPicker(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClickOutside = e => {
|
||||||
|
if (!picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
||||||
|
closeAssetPicker();
|
||||||
|
document.removeEventListener('mousedown', onClickOutside);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Image resize ──
|
// ── Image resize ──
|
||||||
|
|
||||||
document.addEventListener('mousedown', e => {
|
document.addEventListener('mousedown', e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
const img = e.target.closest('.prose img');
|
const img = e.target.closest('.prose img');
|
||||||
if (!img || img.closest('.md-gallery-item')) return;
|
if (!img || img.closest('.md-gallery-item')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+170
-23
@@ -1,39 +1,79 @@
|
|||||||
// ── Image gallery ──
|
// ── Assets (images, audio, PDFs) ──
|
||||||
|
|
||||||
import { $, esc, api, getCsrf } from './util.js';
|
import { $, esc, api, getCsrf } from './util.js';
|
||||||
|
|
||||||
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
||||||
|
const PDF_EXT = /\.pdf$/i;
|
||||||
|
const IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
|
||||||
|
function fileType(url) {
|
||||||
|
if (IMAGE_EXT.test(url)) return 'image';
|
||||||
|
if (AUDIO_EXT.test(url)) return 'audio';
|
||||||
|
if (PDF_EXT.test(url)) return 'pdf';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
function isAudio(url) { return AUDIO_EXT.test(url); }
|
function isAudio(url) { return AUDIO_EXT.test(url); }
|
||||||
|
|
||||||
function mediaTile(url, extraClass = '') {
|
function mediaTile(url, extraClass = '') {
|
||||||
if (isAudio(url)) {
|
const type = fileType(url);
|
||||||
|
if (type === 'audio') {
|
||||||
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
|
||||||
<i class="ph ph-music-note text-2xl text-t-dim"></i>
|
<i class="ph ph-music-note text-2xl text-t-dim"></i>
|
||||||
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
|
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
if (type === 'pdf') {
|
||||||
|
const name = url.split('/').pop();
|
||||||
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-3">
|
||||||
|
<i class="ph ph-file-pdf text-3xl text-t-danger"></i>
|
||||||
|
<span class="font-pixel text-[8px] text-t-muted truncate w-full text-center">${esc(name)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
|
||||||
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
<img src="${url}" class="w-full h-full object-cover ${extraClass.includes('danger') ? 'opacity-60' : ''}" loading="lazy">
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeFilter = 'all';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
|
// Cards with full bodies — fetched fresh by openGallery so we don't
|
||||||
|
// depend on state.cards which may have body=null during the two-phase
|
||||||
|
// lazy load (loadCards loads /api/cards/meta first, then batches of 25
|
||||||
|
// with bodies). Without this, most cards look body-less and every file
|
||||||
|
// appears orphaned until the last batch completes.
|
||||||
|
let galleryCards = [];
|
||||||
|
|
||||||
function getReferencedUrls() {
|
function getReferencedUrls() {
|
||||||
const urls = new Set();
|
const urls = new Set();
|
||||||
for (const card of state.cards) {
|
for (const card of galleryCards) {
|
||||||
const body = card.body || '';
|
const body = card.body || '';
|
||||||
let m;
|
let m;
|
||||||
|
// Markdown images: 
|
||||||
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
const mdRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||||
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
while ((m = mdRe.exec(body)) !== null) urls.add(m[1].split(/\s/)[0]);
|
||||||
|
// HTML images
|
||||||
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
const htmlRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
while ((m = htmlRe.exec(body)) !== null) urls.add(m[1]);
|
||||||
|
// Slide-image blocks
|
||||||
|
const slideRe = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/g;
|
||||||
|
while ((m = slideRe.exec(body)) !== null) {
|
||||||
|
const srcMatch = m[1].match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||||
|
if (srcMatch) urls.add(srcMatch[1].trim());
|
||||||
|
}
|
||||||
|
// Markdown links to uploads (PDFs, other assets): [text](uploads/...)
|
||||||
|
const linkRe = /\[[^\]]*\]\((uploads\/[^)]+)\)/g;
|
||||||
|
while ((m = linkRe.exec(body)) !== null) urls.add(m[1]);
|
||||||
|
// Plain URL mentions (catches any format we missed above)
|
||||||
|
const plainRe = /uploads\/[a-f0-9]{32}\.[a-z0-9]+/gi;
|
||||||
|
while ((m = plainRe.exec(body)) !== null) urls.add(m[0]);
|
||||||
}
|
}
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCardForUrl(url) {
|
function findCardForUrl(url) {
|
||||||
for (const card of state.cards) {
|
for (const card of galleryCards) {
|
||||||
const body = card.body || '';
|
const body = card.body || '';
|
||||||
if (body.includes(url)) return card;
|
if (body.includes(url)) return card;
|
||||||
}
|
}
|
||||||
@@ -44,7 +84,19 @@ export async function openGallery() {
|
|||||||
const el = $('gallery');
|
const el = $('gallery');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const files = await api('api/uploads');
|
// Fetch both file list and full card bodies in parallel. Using
|
||||||
|
// api('api/cards') (not state.cards) guarantees bodies are present
|
||||||
|
// — state.cards may still be in the two-phase lazy-load with
|
||||||
|
// body=null on most entries.
|
||||||
|
let files = [], cards = [];
|
||||||
|
try {
|
||||||
|
[files, cards] = await Promise.all([
|
||||||
|
api('api/uploads'),
|
||||||
|
api('api/cards'),
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
files = Array.isArray(files) ? files : [];
|
||||||
|
galleryCards = Array.isArray(cards) ? cards : [];
|
||||||
const referenced = getReferencedUrls();
|
const referenced = getReferencedUrls();
|
||||||
const grid = $('gallery-grid');
|
const grid = $('gallery-grid');
|
||||||
const count = $('gallery-count');
|
const count = $('gallery-count');
|
||||||
@@ -61,24 +113,66 @@ export async function openGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = linked.length + orphaned.length;
|
const total = linked.length + orphaned.length;
|
||||||
if (count) count.textContent = `${total} image${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`;
|
if (count) count.textContent = `${total} asset${total !== 1 ? 's' : ''}${orphaned.length ? ` · ${orphaned.length} orphaned` : ''}`;
|
||||||
|
|
||||||
|
// Tab filter
|
||||||
|
const allFiles = [...orphaned, ...linked];
|
||||||
|
const applyFilter = (filter) => {
|
||||||
|
activeFilter = filter;
|
||||||
|
document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.filter === filter);
|
||||||
|
});
|
||||||
|
let visibleCount = 0;
|
||||||
|
grid.querySelectorAll('.gallery-item').forEach(item => {
|
||||||
|
const url = item.dataset.url || '';
|
||||||
|
const type = fileType(url);
|
||||||
|
const show = filter === 'all' || type === filter;
|
||||||
|
item.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleCount++;
|
||||||
|
});
|
||||||
|
// Hide section headers when no items in that section are visible
|
||||||
|
grid.querySelectorAll('[data-section]:not(.gallery-item)').forEach(hdr => {
|
||||||
|
const section = hdr.dataset.section;
|
||||||
|
const hasVisible = !!grid.querySelector(`.gallery-item[data-section="${section}"]:not([style*="display: none"])`);
|
||||||
|
hdr.style.display = hasVisible ? '' : 'none';
|
||||||
|
});
|
||||||
|
// Show/hide the empty-filter placeholder
|
||||||
|
let placeholder = grid.querySelector('.gallery-filter-empty');
|
||||||
|
if (visibleCount === 0 && total > 0) {
|
||||||
|
if (!placeholder) {
|
||||||
|
placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'gallery-filter-empty col-span-full text-center py-16';
|
||||||
|
const icons = { image: 'ph-image', audio: 'ph-music-note', pdf: 'ph-file-pdf' };
|
||||||
|
const icon = icons[filter] || 'ph-folder-open';
|
||||||
|
placeholder.innerHTML = `<i class="ph ${icon} text-4xl text-t-dim mb-2 block"></i><p class="font-pixel text-[10px] text-t-dim">no ${filter} assets</p>`;
|
||||||
|
grid.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
placeholder.style.display = '';
|
||||||
|
} else if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#gallery-tabs .gallery-tab').forEach(t => {
|
||||||
|
t.onclick = () => applyFilter(t.dataset.filter);
|
||||||
|
});
|
||||||
|
|
||||||
if (!total) {
|
if (!total) {
|
||||||
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
grid.innerHTML = `<div class="col-span-full text-center py-16">
|
||||||
<i class="ph ph-image text-4xl text-t-dim mb-2 block"></i>
|
<i class="ph ph-folder-open text-4xl text-t-dim mb-2 block"></i>
|
||||||
<p class="font-pixel text-[10px] text-t-dim">no images</p></div>`;
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no assets</p></div>`;
|
||||||
} else {
|
} else {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (orphaned.length) {
|
if (orphaned.length) {
|
||||||
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2" data-section="orphaned"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
||||||
html += orphaned.map(f => `
|
html += orphaned.map(f => `
|
||||||
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}">
|
<div class="gallery-item group cursor-pointer" data-orphan="${f.name}" data-url="${esc(f.url)}" data-section="orphaned">
|
||||||
<div class="relative aspect-square bg-t-bg border border-t-danger/30 rounded overflow-visible">
|
<div class="relative">
|
||||||
<img src="${f.url}" class="w-full h-full object-cover opacity-60 rounded" loading="lazy">
|
${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-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-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}" 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>
|
||||||
@@ -86,9 +180,9 @@ export async function openGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (linked.length) {
|
if (linked.length) {
|
||||||
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3" data-section="linked"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
||||||
html += linked.map(img => `
|
html += linked.map(img => `
|
||||||
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}">
|
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}" data-url="${esc(img.url)}" data-section="linked">
|
||||||
${mediaTile(img.url, 'border-t-border')}
|
${mediaTile(img.url, 'border-t-border')}
|
||||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
|
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -111,13 +205,16 @@ export async function openGallery() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orphan: click to enlarge
|
// Orphan: click to enlarge (images) or open in new tab (PDFs)
|
||||||
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
grid.querySelectorAll('[data-orphan]').forEach(item => {
|
||||||
item.addEventListener('click', e => {
|
item.addEventListener('click', e => {
|
||||||
if (e.target.closest('button')) return;
|
if (e.target.closest('button')) return;
|
||||||
const name = item.dataset.orphan;
|
const name = item.dataset.orphan;
|
||||||
const f = orphaned.find(o => o.name === name);
|
const f = orphaned.find(o => o.name === name);
|
||||||
if (f && !isAudio(f.url)) openOrphanLightbox(f);
|
if (!f) return;
|
||||||
|
const type = fileType(f.url);
|
||||||
|
if (type === 'pdf') window.open(f.url, '_blank');
|
||||||
|
else if (type === 'image') openOrphanLightbox(f);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +225,23 @@ export async function openGallery() {
|
|||||||
const name = btn.dataset.newOrphan;
|
const name = btn.dataset.newOrphan;
|
||||||
const label = name.replace(/\.[^.]+$/, '');
|
const label = name.replace(/\.[^.]+$/, '');
|
||||||
closeGallery();
|
closeGallery();
|
||||||
window._openModal({ id: null, title: label, body: ``, color: '#38bdf8', bucket_id: null });
|
const type = fileType('uploads/' + name);
|
||||||
|
const body = type === 'pdf'
|
||||||
|
? `[📄 ${name}](uploads/${name})`
|
||||||
|
: ``;
|
||||||
|
window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Orphan: inline new slide button (creates image slide with slide-image block)
|
||||||
|
grid.querySelectorAll('[data-new-slide]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const name = btn.dataset.newSlide;
|
||||||
|
const label = name.replace(/\.[^.]+$/, '');
|
||||||
|
closeGallery();
|
||||||
|
const body = '```slide-image\nsrc: uploads/' + name + '\n```';
|
||||||
|
window._openModal({ id: null, title: label, body, color: '#38bdf8', bucket_id: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +251,7 @@ export async function openGallery() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = btn.dataset.delOrphan;
|
const name = btn.dataset.delOrphan;
|
||||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||||
const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
|
const ok = await showDialog({ title: 'delete_image', message: `Delete "${name}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||||
@@ -147,6 +260,9 @@ export async function openGallery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the active filter (persists across re-opens within a session)
|
||||||
|
applyFilter(activeFilter);
|
||||||
|
|
||||||
el.classList.add('open');
|
el.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +277,13 @@ function openOrphanLightbox(file) {
|
|||||||
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
||||||
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
||||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">New card</span>
|
||||||
|
</button>
|
||||||
|
<button class="orphan-lb-slide flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||||
|
<i class="ph ph-image-square text-sm"></i><span class="font-pixel text-[10px]">New slide</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
||||||
@@ -183,8 +302,16 @@ function openOrphanLightbox(file) {
|
|||||||
window._openModal({ id: null, title: name, body: ``, color: '#38bdf8', bucket_id: null });
|
window._openModal({ id: null, title: name, body: ``, color: '#38bdf8', bucket_id: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.orphan-lb-slide').addEventListener('click', async () => {
|
||||||
|
const name = file.url.split('/').pop().replace(/\.[^.]+$/, '');
|
||||||
|
close();
|
||||||
|
closeGallery();
|
||||||
|
const body = '```slide-image\nsrc: ' + file.url + '\n```';
|
||||||
|
window._openModal({ id: null, title: name, body, color: '#38bdf8', bucket_id: null });
|
||||||
|
});
|
||||||
|
|
||||||
overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
|
overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
|
||||||
const ok = await showDialog({ title: 'delete_image', message: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' });
|
const ok = await showDialog({ title: 'delete_image', message: `Delete "${file.name}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
|
||||||
close();
|
close();
|
||||||
@@ -201,3 +328,23 @@ export function closeGallery() {
|
|||||||
$('gallery-close')?.addEventListener('click', closeGallery);
|
$('gallery-close')?.addEventListener('click', closeGallery);
|
||||||
$('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); });
|
$('gallery')?.addEventListener('click', e => { if (e.target === $('gallery')) closeGallery(); });
|
||||||
$('gallery-btn')?.addEventListener('click', openGallery);
|
$('gallery-btn')?.addEventListener('click', openGallery);
|
||||||
|
|
||||||
|
// Upload button
|
||||||
|
$('gallery-upload')?.addEventListener('click', () => $('gallery-upload-file')?.click());
|
||||||
|
$('gallery-upload-file')?.addEventListener('change', async e => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
for (const file of files) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, file.name);
|
||||||
|
try {
|
||||||
|
await fetch('api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
await openGallery();
|
||||||
|
});
|
||||||
@@ -11,6 +11,32 @@ marked.use({ breaks: true, gfm: true, renderer: {
|
|||||||
}
|
}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
// ── Built-in fenced block handlers ──
|
||||||
|
// Registered through the same plugin API so plugins and core use one path.
|
||||||
|
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||||
|
const md = window.hyprcrd.markdown;
|
||||||
|
|
||||||
|
// ```gallery — image grid
|
||||||
|
md.registerFencedBlock('gallery', (content, { esc: e }) => {
|
||||||
|
const imgs = [];
|
||||||
|
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||||
|
if (!imgs.length) return '';
|
||||||
|
return '<div class="md-gallery">' + imgs.map(i =>
|
||||||
|
`<div class="md-gallery-item"><img src="${i.src}" alt="${e(i.alt)}" loading="lazy"></div>`
|
||||||
|
).join('') + '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ```tree / ```bullets — custom list rendering (handled via post-processor)
|
||||||
|
const listHandler = (kind) => (content, { normalizeListBlock: norm }) => {
|
||||||
|
const normalized = norm(content);
|
||||||
|
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
||||||
|
};
|
||||||
|
md.registerFencedBlock('tree', listHandler('tree'));
|
||||||
|
md.registerFencedBlock('bullets', listHandler('bullets'));
|
||||||
|
}
|
||||||
|
|
||||||
const purifyConfig = {
|
const purifyConfig = {
|
||||||
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
||||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
||||||
@@ -79,23 +105,32 @@ export function renderMd(src) {
|
|||||||
'</tbody></table>\n';
|
'</tbody></table>\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Fenced block registry ─────────────────────────────────────
|
||||||
|
// Apply all registered ```<lang> handlers (built-in + plugins).
|
||||||
|
// Plugins register via window.hyprcrd.markdown.registerFencedBlock().
|
||||||
|
const fencedBlocks = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getFencedBlocks)
|
||||||
|
? window.hyprcrd.markdown.getFencedBlocks()
|
||||||
|
: [];
|
||||||
|
for (const { lang, handler } of fencedBlocks) {
|
||||||
|
// Escape regex metachars in lang (allow simple identifiers)
|
||||||
|
const safeLang = String(lang).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp('```' + safeLang + '\\s*\\n([\\s\\S]*?)(?:```|$)', 'g');
|
||||||
|
processed = processed.replace(re, (_, content) => {
|
||||||
|
try { return handler(content, { esc, normalizeListBlock }); }
|
||||||
|
catch (err) { console.error(`[markdown fenced ${lang}]`, err); return ''; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin pre-processors ─────────────────────────────────────
|
||||||
|
const preProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPreProcessors)
|
||||||
|
? window.hyprcrd.markdown.getPreProcessors()
|
||||||
|
: [];
|
||||||
|
for (const fn of preProcessors) {
|
||||||
|
try { processed = fn(processed) ?? processed; }
|
||||||
|
catch (err) { console.error('[markdown pre]', err); }
|
||||||
|
}
|
||||||
|
|
||||||
processed = processed
|
processed = processed
|
||||||
// Gallery fenced block: ```gallery
|
|
||||||
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
|
||||||
const imgs = [];
|
|
||||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
||||||
let m;
|
|
||||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
|
||||||
if (!imgs.length) return '';
|
|
||||||
return '<div class="md-gallery">' + imgs.map(i =>
|
|
||||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${esc(i.alt)}" loading="lazy"></div>`
|
|
||||||
).join('') + '</div>';
|
|
||||||
})
|
|
||||||
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
|
|
||||||
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
|
|
||||||
const normalized = normalizeListBlock(content);
|
|
||||||
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
|
||||||
})
|
|
||||||
// Help parser: ensure a list can start after text without requiring manual blank line
|
// Help parser: ensure a list can start after text without requiring manual blank line
|
||||||
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
||||||
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
||||||
@@ -111,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);
|
||||||
@@ -120,10 +155,24 @@ export function renderMd(src) {
|
|||||||
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
|
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
|
||||||
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
|
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
|
||||||
|
|
||||||
|
// ── Plugin post-processors (run on HTML, before sanitize) ──
|
||||||
|
const postProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPostProcessors)
|
||||||
|
? window.hyprcrd.markdown.getPostProcessors()
|
||||||
|
: [];
|
||||||
|
for (const fn of postProcessors) {
|
||||||
|
try { html = fn(html) ?? html; }
|
||||||
|
catch (err) { console.error('[markdown post]', err); }
|
||||||
|
}
|
||||||
|
|
||||||
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
|
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose to plugins via the hyprcrd API
|
||||||
|
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||||
|
window.hyprcrd.markdown.render = renderMd;
|
||||||
|
}
|
||||||
|
|
||||||
export function addCodeCopyButtons(container) {
|
export function addCodeCopyButtons(container) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.querySelectorAll('pre > code').forEach(code => {
|
container.querySelectorAll('pre > code').forEach(code => {
|
||||||
@@ -194,12 +243,23 @@ export function getTodos(src) {
|
|||||||
return { total, done: (s.match(/- \[x\]/g) || []).length };
|
return { total, done: (s.match(/- \[x\]/g) || []).length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches the `src:` line inside a ```slide-image fenced block (slideshow plugin format).
|
||||||
|
const SLIDE_IMAGE_RE = /```slide-image\s*\n([\s\S]*?)(?:\n```|$)/;
|
||||||
|
function slideImageSrc(block) {
|
||||||
|
if (!block) return null;
|
||||||
|
const m = block.match(/^\s*src\s*:\s*([^\n]+)/m);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function firstImage(src) {
|
export function firstImage(src) {
|
||||||
const s = src || '';
|
const s = src || '';
|
||||||
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
|
||||||
if (md) return md[1];
|
if (md) return md[1];
|
||||||
const html = s.match(/<img[^>]+src="([^"]+)"/i);
|
const html = s.match(/<img[^>]+src="([^"]+)"/i);
|
||||||
return html ? html[1] : null;
|
if (html) return html[1];
|
||||||
|
const slide = s.match(SLIDE_IMAGE_RE);
|
||||||
|
if (slide) return slideImageSrc(slide[1]);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function leadingImage(src) {
|
export function leadingImage(src) {
|
||||||
@@ -208,5 +268,9 @@ export function leadingImage(src) {
|
|||||||
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
|
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
|
||||||
if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null;
|
if (md) return md[1].split(/\s+/)[0].replace(/^<(.+)>$/, '$1') || null;
|
||||||
const html = s.match(/^<img[^>]+src=(['"])(.*?)\1/i);
|
const html = s.match(/^<img[^>]+src=(['"])(.*?)\1/i);
|
||||||
return html ? html[2] : null;
|
if (html) return html[2];
|
||||||
|
// A card whose body *starts* with a slide-image block → use its image as the cover.
|
||||||
|
const slide = s.match(/^```slide-image\s*\n([\s\S]*?)(?:\n```|$)/);
|
||||||
|
if (slide) return slideImageSrc(slide[1]);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
@@ -73,6 +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" 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 => {
|
||||||
@@ -88,6 +89,10 @@ export function render() {
|
|||||||
dismiss(btn.dataset.dismiss);
|
dismiss(btn.dataset.dismiss);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
banner.querySelector('#dismiss-all')?.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
for (const i of visible) dismiss(String(i.id));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings panel integration ──
|
// ── Settings panel integration ──
|
||||||
@@ -143,7 +148,7 @@ function renderStream() {
|
|||||||
const all = getAllItems();
|
const all = getAllItems();
|
||||||
|
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4">no notifications</div>';
|
stream.innerHTML = '<div class="text-xs text-t-dim font-pixel text-center py-4 cursor-blink">no notifications</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// ── Plugin Settings UI ──
|
||||||
|
// Toggles collect pending changes client-side and commit together via
|
||||||
|
// the Apply button. Only reloads once all changes are committed.
|
||||||
|
|
||||||
|
import { $, esc, api } from './util.js';
|
||||||
|
|
||||||
|
let pluginsCache = null;
|
||||||
|
let pending = new Map(); // name -> desired enabled state
|
||||||
|
|
||||||
|
async function fetchPlugins() {
|
||||||
|
const res = await fetch('api/plugins');
|
||||||
|
if (!res.ok) throw new Error('failed to load plugins');
|
||||||
|
pluginsCache = await res.json();
|
||||||
|
return pluginsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(list) {
|
||||||
|
const container = $('plugins-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (!list.length) {
|
||||||
|
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = list.map(p => {
|
||||||
|
const effectiveEnabled = pending.has(p.name) ? pending.get(p.name) : p.enabled;
|
||||||
|
const isPending = pending.has(p.name);
|
||||||
|
return `
|
||||||
|
<div class="flex items-start gap-3 px-3 py-2 border ${isPending ? 'border-t-accent' : 'border-t-border'} bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
||||||
|
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
||||||
|
${isPending ? `<span class="font-pixel text-[8px] px-1 py-0.5 text-t-accent border border-t-accent uppercase">${effectiveEnabled ? 'will enable' : 'will disable'}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||||
|
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${effectiveEnabled ? 'checked' : ''} data-name="${esc(p.name)}" data-original="${p.enabled ? '1' : '0'}">
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
||||||
|
input.addEventListener('change', onToggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateApplyBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle(e) {
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const name = input.dataset.name;
|
||||||
|
const original = input.dataset.original === '1';
|
||||||
|
const desired = input.checked;
|
||||||
|
if (desired === original) {
|
||||||
|
pending.delete(name);
|
||||||
|
} else {
|
||||||
|
pending.set(name, desired);
|
||||||
|
}
|
||||||
|
render(pluginsCache); // re-render to update badge and border
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateApplyBar() {
|
||||||
|
const bar = $('plugins-apply-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
if (pending.size === 0) {
|
||||||
|
bar.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.classList.remove('hidden');
|
||||||
|
const count = pending.size;
|
||||||
|
const label = $('plugins-apply-count');
|
||||||
|
if (label) label.textContent = `${count} pending change${count !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChanges() {
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
const btn = $('plugins-apply-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'applying...'; }
|
||||||
|
try {
|
||||||
|
for (const [name, enabled] of pending) {
|
||||||
|
const res = await api('api/plugins/toggle', { method: 'POST', body: { name, enabled } });
|
||||||
|
if (res.error) throw new Error(`${name}: ${res.error}`);
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
// Reload so the new plugin set takes effect
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Apply'; }
|
||||||
|
alert('Failed to apply: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardChanges() {
|
||||||
|
pending.clear();
|
||||||
|
render(pluginsCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPluginSettings() {
|
||||||
|
const container = $('plugins-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
||||||
|
try {
|
||||||
|
const list = await fetchPlugins();
|
||||||
|
render(list);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<p class="text-xs text-t-danger">Failed to load plugins: ${esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('plugins-apply-btn')?.addEventListener('click', applyChanges);
|
||||||
|
$('plugins-discard-btn')?.addEventListener('click', discardChanges);
|
||||||
+254
-12
@@ -3,6 +3,7 @@
|
|||||||
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
|
||||||
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
import { state, parseTags, tagChipHtml, getChildren } from './state.js';
|
||||||
import { renderMd, stripMd } from './markdown.js';
|
import { renderMd, stripMd } from './markdown.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const overlay = $('search');
|
const overlay = $('search');
|
||||||
const input = $('search-input');
|
const input = $('search-input');
|
||||||
@@ -11,29 +12,74 @@ const footer = $('search-footer');
|
|||||||
const previewEmpty = $('search-preview-empty');
|
const previewEmpty = $('search-preview-empty');
|
||||||
const previewContent = $('search-preview-content');
|
const previewContent = $('search-preview-content');
|
||||||
const bucketFilter = $('search-bucket-filter');
|
const bucketFilter = $('search-bucket-filter');
|
||||||
let active = -1, hits = [];
|
const replaceToggle = $('search-replace-toggle');
|
||||||
|
const replaceRow = $('search-replace-row');
|
||||||
|
const replaceInput = $('search-replace-input');
|
||||||
|
const replaceStatus = $('search-replace-status');
|
||||||
|
const replaceAllBtn = $('search-replace-all');
|
||||||
|
let active = -1, hits = [], replaceMode = false, freshCards = null;
|
||||||
|
|
||||||
export function openSearch() {
|
export function openSearch({ replace = false } = {}) {
|
||||||
window._closeTasks?.();
|
window._closeTasks?.();
|
||||||
input.value = '';
|
input.value = '';
|
||||||
results.innerHTML = '';
|
results.innerHTML = '';
|
||||||
footer.classList.add('hidden');
|
footer.classList.add('hidden');
|
||||||
active = -1;
|
active = -1;
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
|
setReplaceMode(replace);
|
||||||
input.focus();
|
input.focus();
|
||||||
clearPreview();
|
clearPreview();
|
||||||
renderHome();
|
renderHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSearch() { overlay.classList.remove('open'); }
|
async function setReplaceMode(on) {
|
||||||
|
replaceMode = on;
|
||||||
|
replaceRow?.classList.toggle('hidden', !on);
|
||||||
|
replaceToggle?.classList.toggle('active', on);
|
||||||
|
if (on) {
|
||||||
|
replaceInput.value = '';
|
||||||
|
freshCards = await api('api/cards') || [];
|
||||||
|
} else {
|
||||||
|
freshCards = null;
|
||||||
|
}
|
||||||
|
updateReplaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReplaceState() {
|
||||||
|
if (!replaceMode || !replaceAllBtn) return;
|
||||||
|
const q = input.value.trim();
|
||||||
|
const hasRepl = replaceInput?.value.length > 0;
|
||||||
|
const hasMatch = hits.length > 0 && q.length > 0;
|
||||||
|
replaceAllBtn.disabled = !(hasMatch && hasRepl);
|
||||||
|
if (q && hits.length) {
|
||||||
|
let total = 0;
|
||||||
|
const nLower = q.toLowerCase();
|
||||||
|
for (const c of hits) {
|
||||||
|
const body = (c.body || '').toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; total++; pos = i + 1; }
|
||||||
|
}
|
||||||
|
replaceStatus.textContent = total ? `${total} in ${hits.length} card${hits.length !== 1 ? 's' : ''}` : '';
|
||||||
|
} else {
|
||||||
|
replaceStatus.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSearch() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
||||||
|
|
||||||
function getPool() {
|
function getPool() {
|
||||||
|
const cards = (replaceMode && freshCards) ? freshCards : state.cards;
|
||||||
const bv = bucketFilter.value;
|
const bv = bucketFilter.value;
|
||||||
if (!bv) return state.cards;
|
if (!bv) return cards;
|
||||||
if (bv === 'none') return state.cards.filter(c => !c.bucket_id);
|
if (bv === 'none') return cards.filter(c => !c.bucket_id);
|
||||||
return state.cards.filter(c => c.bucket_id == bv);
|
return cards.filter(c => c.bucket_id == bv);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
@@ -80,12 +126,12 @@ function updatePreview() {
|
|||||||
|
|
||||||
function renderHome() {
|
function renderHome() {
|
||||||
const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
|
const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
|
||||||
if (!recent.length) { results.innerHTML = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel">no cards yet</div>`; return; }
|
if (!recent.length) { results.innerHTML = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel cursor-blink">no cards yet</div>`; return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>` +
|
||||||
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
recent.map((c, i) => hitHtml(c, i, null)).join('');
|
||||||
hits = recent; active = recent.length ? 0 : -1;
|
hits = recent; active = recent.length ? 0 : -1;
|
||||||
footer.classList.remove('hidden');
|
footer.classList.remove('hidden');
|
||||||
bindHits(); updateActive(); updatePreview();
|
bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(query) {
|
function search(query) {
|
||||||
@@ -110,10 +156,10 @@ function search(query) {
|
|||||||
// Tag-only search (no words)
|
// Tag-only search (no words)
|
||||||
if (!words.length) {
|
if (!words.length) {
|
||||||
hits = pool; active = pool.length ? 0 : -1;
|
hits = pool; active = pool.length ? 0 : -1;
|
||||||
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
if (!pool.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
||||||
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
pool.map((c, i) => hitHtml(c, i, null)).join('');
|
||||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
|
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState(); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scored = pool.map(c => {
|
const scored = pool.map(c => {
|
||||||
@@ -141,10 +187,10 @@ function search(query) {
|
|||||||
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
}).filter(Boolean).sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
hits = scored.map(s => s.card); active = scored.length ? 0 : -1;
|
||||||
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
if (!scored.length) { results.innerHTML = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel cursor-blink">no results for "${esc(query)}"</p></div>`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; }
|
||||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
|
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>` +
|
||||||
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
|
scored.map((s, i) => hitHtml(s.card, i, words)).join('');
|
||||||
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview();
|
footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hitHtml(card, idx, words) {
|
function hitHtml(card, idx, words) {
|
||||||
@@ -180,6 +226,66 @@ function hitHtml(card, idx, words) {
|
|||||||
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBucketPicker(cardId, x, y) {
|
||||||
|
document.querySelector('.search-bucket-picker')?.remove();
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.className = 'search-bucket-picker';
|
||||||
|
// Clamp to viewport so a click near the bottom-right doesn't flow
|
||||||
|
// off-screen — rendered invisibly first to measure, then placed.
|
||||||
|
picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`;
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.style.cssText = 'overflow-y:auto;flex:1;';
|
||||||
|
|
||||||
|
const addRow = (label, color, onClick) => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
|
||||||
|
item.innerHTML = `<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(label)}</span>`;
|
||||||
|
item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; });
|
||||||
|
item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; });
|
||||||
|
item.addEventListener('click', onClick);
|
||||||
|
list.appendChild(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
addRow('(unsorted)', 'transparent', async () => {
|
||||||
|
picker.remove();
|
||||||
|
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } });
|
||||||
|
await window._reloadAll();
|
||||||
|
search(input.value);
|
||||||
|
});
|
||||||
|
state.buckets.forEach(b => {
|
||||||
|
addRow(b.name, b.color || '#64748b', async () => {
|
||||||
|
picker.remove();
|
||||||
|
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } });
|
||||||
|
await window._reloadAll();
|
||||||
|
search(input.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
picker.appendChild(list);
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
|
||||||
|
// Place, then flip if we'd overflow the viewport.
|
||||||
|
const r = picker.getBoundingClientRect();
|
||||||
|
let left = x, top = y;
|
||||||
|
if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8);
|
||||||
|
if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8);
|
||||||
|
picker.style.left = left + 'px';
|
||||||
|
picker.style.top = top + 'px';
|
||||||
|
picker.style.visibility = '';
|
||||||
|
|
||||||
|
const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } };
|
||||||
|
const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } };
|
||||||
|
const cleanup = () => {
|
||||||
|
document.removeEventListener('mousedown', onDown, true);
|
||||||
|
document.removeEventListener('keydown', onEsc, true);
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('mousedown', onDown, true);
|
||||||
|
document.addEventListener('keydown', onEsc, true);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function bindHits() {
|
function bindHits() {
|
||||||
results.querySelectorAll('.search-hit').forEach(el => {
|
results.querySelectorAll('.search-hit').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
@@ -188,6 +294,16 @@ function bindHits() {
|
|||||||
else { active = i; updateActive(); }
|
else { active = i; updateActive(); }
|
||||||
});
|
});
|
||||||
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
|
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
|
||||||
|
el.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = el.dataset.cardId;
|
||||||
|
if (!id) return;
|
||||||
|
// Highlight the row so the user sees which card the picker
|
||||||
|
// will move — mirrors the active-on-click behaviour.
|
||||||
|
const i = parseInt(el.dataset.idx);
|
||||||
|
if (!isNaN(i)) { active = i; updateActive(); }
|
||||||
|
openBucketPicker(id, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
// Drag: only non-group, non-child cards
|
// Drag: only non-group, non-child cards
|
||||||
if (el.draggable) {
|
if (el.draggable) {
|
||||||
@@ -246,3 +362,129 @@ input.addEventListener('keydown', e => {
|
|||||||
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); });
|
||||||
$('search-btn')?.addEventListener('click', openSearch);
|
$('search-btn')?.addEventListener('click', openSearch);
|
||||||
$('search-close')?.addEventListener('click', closeSearch);
|
$('search-close')?.addEventListener('click', closeSearch);
|
||||||
|
replaceToggle?.addEventListener('click', () => setReplaceMode(!replaceMode));
|
||||||
|
replaceInput?.addEventListener('input', updateReplaceState);
|
||||||
|
|
||||||
|
replaceAllBtn?.addEventListener('click', async () => {
|
||||||
|
const needle = input.value.trim();
|
||||||
|
const repl = replaceInput.value;
|
||||||
|
if (!needle || !hits.length) return;
|
||||||
|
let totalOcc = 0;
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
for (const c of hits) {
|
||||||
|
let pos = 0;
|
||||||
|
const body = (c.body || '').toLowerCase();
|
||||||
|
while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; totalOcc++; pos = i + 1; }
|
||||||
|
}
|
||||||
|
const ok = await showDialog({
|
||||||
|
title: 'replace_all',
|
||||||
|
message: `Replace ${totalOcc} occurrence${totalOcc !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${hits.length} card${hits.length !== 1 ? 's' : ''}?`,
|
||||||
|
danger: true, okLabel: `Replace ${totalOcc}`,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const freshCards = await api('api/cards') || [];
|
||||||
|
const freshMap = new Map(freshCards.map(c => [c.id, c]));
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(escaped, 'gi');
|
||||||
|
let patched = 0;
|
||||||
|
for (const c of hits) {
|
||||||
|
const fresh = freshMap.get(c.id);
|
||||||
|
const body = fresh?.body || c.body || '';
|
||||||
|
const newBody = body.replace(re, repl);
|
||||||
|
if (newBody !== body) {
|
||||||
|
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaceStatus.textContent = `done — ${patched} card${patched !== 1 ? 's' : ''} updated`;
|
||||||
|
replaceStatus.style.color = 'var(--t-accent)';
|
||||||
|
replaceAllBtn.disabled = true;
|
||||||
|
await window._reloadAll();
|
||||||
|
search(input.value);
|
||||||
|
setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Saved searches ──────────────────────────────────────────────
|
||||||
|
// localStorage-persisted list of search strings. The bookmark button
|
||||||
|
// toggles a popup that lets the user pick one (fills input + runs),
|
||||||
|
// save the current input value, or delete saved entries.
|
||||||
|
|
||||||
|
const SAVED_KEY = 'hyprcrd-saved-searches';
|
||||||
|
const savedBtn = $('search-saved-btn');
|
||||||
|
const savedPopup = $('search-saved-popup');
|
||||||
|
const savedList = $('search-saved-list');
|
||||||
|
const savedAdd = $('search-saved-add');
|
||||||
|
|
||||||
|
function loadSaved() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
function storeSaved(arr) {
|
||||||
|
localStorage.setItem(SAVED_KEY, JSON.stringify(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSavedList() {
|
||||||
|
if (!savedList) return;
|
||||||
|
const items = loadSaved();
|
||||||
|
if (!items.length) {
|
||||||
|
savedList.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel text-center">no saved searches</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savedList.innerHTML = items.map((q, i) => `
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-t-raised cursor-pointer transition-colors search-saved-row" data-idx="${i}">
|
||||||
|
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
|
||||||
|
<span class="flex-1 text-xs text-t-muted truncate">${esc(q)}</span>
|
||||||
|
<button class="search-saved-del text-t-dim hover:text-t-danger text-xs px-1" data-idx="${i}" title="remove"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
savedList.querySelectorAll('.search-saved-row').forEach(row => {
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.search-saved-del')) return;
|
||||||
|
const q = items[parseInt(row.dataset.idx)];
|
||||||
|
if (q !== undefined) {
|
||||||
|
input.value = q;
|
||||||
|
search(q);
|
||||||
|
closeSavedPopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
savedList.querySelectorAll('.search-saved-del').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
const arr = loadSaved();
|
||||||
|
arr.splice(idx, 1);
|
||||||
|
storeSaved(arr);
|
||||||
|
renderSavedList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSavedPopup() {
|
||||||
|
renderSavedList();
|
||||||
|
savedPopup?.classList.remove('hidden');
|
||||||
|
const close = (e) => {
|
||||||
|
if (savedPopup?.contains(e.target) || e.target === savedBtn) return;
|
||||||
|
closeSavedPopup();
|
||||||
|
document.removeEventListener('mousedown', close, true);
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
|
||||||
|
}
|
||||||
|
function closeSavedPopup() {
|
||||||
|
savedPopup?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
savedBtn?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (savedPopup?.classList.contains('hidden')) openSavedPopup();
|
||||||
|
else closeSavedPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
savedAdd?.addEventListener('click', () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const arr = loadSaved();
|
||||||
|
if (arr.includes(q)) return; // no dupes
|
||||||
|
arr.unshift(q);
|
||||||
|
storeSaved(arr);
|
||||||
|
renderSavedList();
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// ── Central keyboard shortcut registry ──
|
||||||
|
//
|
||||||
|
// Every keyboard shortcut — bare keys, combos, context-specific — is
|
||||||
|
// registered here via registerShortcut(). A single document-level
|
||||||
|
// keydown listener dispatches to the matching handler. The registry
|
||||||
|
// also drives the auto-generated #shortcuts help screen so the UI
|
||||||
|
// and the code always agree on what's available.
|
||||||
|
//
|
||||||
|
// Shortcuts that live on specific DOM elements (editor textarea,
|
||||||
|
// search input, canvas overlay, dialog) stay element-scoped because
|
||||||
|
// they depend on local module state (autocomplete visibility, etc.).
|
||||||
|
// Only document-level shortcuts are centralized here.
|
||||||
|
|
||||||
|
const registry = [];
|
||||||
|
|
||||||
|
const OVERLAY_IDS = ['modal', 'detail', 'search', 'calendar', 'gallery',
|
||||||
|
'settings-overlay', 'help', 'shortcuts', 'email-overlay', 'tasks',
|
||||||
|
'dialog', 'ascii-canvas-overlay'];
|
||||||
|
|
||||||
|
function isOpen(id) {
|
||||||
|
return document.getElementById(id)?.classList.contains('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function noOverlay() {
|
||||||
|
return !OVERLAY_IDS.some(isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyFocused() {
|
||||||
|
return document.activeElement === document.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a keyboard shortcut.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.key — e.key value to match (e.g. 'a', 'Enter', 'Escape', '?')
|
||||||
|
* @param {boolean} [opts.ctrl] — require Ctrl/Cmd
|
||||||
|
* @param {boolean} [opts.shift] — require Shift
|
||||||
|
* @param {boolean} [opts.alt] — require Alt
|
||||||
|
* @param {function} [opts.match] — custom predicate (e) => bool; overrides key/ctrl/shift/alt
|
||||||
|
* @param {function} [opts.guard] — extra runtime check; shortcut only fires when guard() is truthy
|
||||||
|
* @param {number} [opts.priority] — higher = checked first (default 0)
|
||||||
|
* @param {boolean} [opts.stop] — call e.stopPropagation() + e.stopImmediatePropagation()
|
||||||
|
* @param {string} opts.section — help-screen section header
|
||||||
|
* @param {string} opts.label — human description for the help screen
|
||||||
|
* @param {string} [opts.keys] — display string for the help screen (e.g. '⌃ S'); auto-built if omitted
|
||||||
|
* @param {boolean} [opts.hidden] — true to suppress from the help screen
|
||||||
|
* @param {function} opts.handler — (e: KeyboardEvent) => void
|
||||||
|
*/
|
||||||
|
export function registerShortcut(opts) {
|
||||||
|
registry.push({
|
||||||
|
key: opts.key ?? null,
|
||||||
|
ctrl: !!opts.ctrl,
|
||||||
|
shift: !!opts.shift,
|
||||||
|
alt: !!opts.alt,
|
||||||
|
match: opts.match ?? null,
|
||||||
|
guard: opts.guard ?? null,
|
||||||
|
priority: opts.priority ?? 0,
|
||||||
|
stop: !!opts.stop,
|
||||||
|
section: opts.section ?? 'misc',
|
||||||
|
label: opts.label ?? '',
|
||||||
|
keys: opts.keys ?? null,
|
||||||
|
hidden: !!opts.hidden,
|
||||||
|
handler: opts.handler,
|
||||||
|
});
|
||||||
|
// Keep sorted by priority desc so the dispatch loop can short-circuit.
|
||||||
|
registry.sort((a, b) => b.priority - a.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the full registry for the auto-generated help screen. */
|
||||||
|
export function getShortcuts() {
|
||||||
|
return registry.filter(s => !s.hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility guards for use in shortcut definitions. */
|
||||||
|
export const guards = {
|
||||||
|
board: () => bodyFocused() && noOverlay(),
|
||||||
|
editor: () => isOpen('modal'),
|
||||||
|
detail: () => isOpen('detail'),
|
||||||
|
search: () => isOpen('search'),
|
||||||
|
email: () => isOpen('email-overlay'),
|
||||||
|
canvas: () => isOpen('ascii-canvas-overlay'),
|
||||||
|
noOverlay,
|
||||||
|
bodyFocused,
|
||||||
|
isOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dispatcher ──
|
||||||
|
|
||||||
|
function isEditableTarget() {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const inEditable = isEditableTarget();
|
||||||
|
|
||||||
|
for (const s of registry) {
|
||||||
|
// Key match
|
||||||
|
let matched;
|
||||||
|
if (s.match) {
|
||||||
|
matched = s.match(e);
|
||||||
|
} else {
|
||||||
|
if (s.key !== e.key) continue;
|
||||||
|
if (s.ctrl !== (e.ctrlKey || e.metaKey)) continue;
|
||||||
|
if (s.shift !== e.shiftKey) continue;
|
||||||
|
if (s.alt !== e.altKey) continue;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
// Suppress non-modifier shortcuts when focus is in an editable field
|
||||||
|
// (allow Escape and function keys like F1 through)
|
||||||
|
if (inEditable && !s.ctrl && !s.alt && !s.match && e.key !== 'Escape' && !e.key.startsWith('F')) continue;
|
||||||
|
|
||||||
|
// Guard
|
||||||
|
if (s.guard && !s.guard()) continue;
|
||||||
|
|
||||||
|
// Fire
|
||||||
|
e.preventDefault();
|
||||||
|
if (s.stop) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
s.handler(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto-generated help screen ──
|
||||||
|
|
||||||
|
/** Render the #shortcuts overlay content from the registry.
|
||||||
|
* Called once after all modules have registered their shortcuts
|
||||||
|
* (typically at the end of app.js boot). */
|
||||||
|
export function renderShortcutHelp() {
|
||||||
|
const container = document.querySelector('#shortcuts .shortcut-help-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const visible = getShortcuts();
|
||||||
|
// Group by section, preserve registration order within each section
|
||||||
|
const sections = new Map();
|
||||||
|
for (const s of visible) {
|
||||||
|
if (!sections.has(s.section)) sections.set(s.section, []);
|
||||||
|
sections.get(s.section).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML — each section is a block with a header + table.
|
||||||
|
let html = '';
|
||||||
|
for (const [name, shortcuts] of sections) {
|
||||||
|
html += `<section>
|
||||||
|
<h3 class="font-pixel text-[10px] text-t-accent mb-2 uppercase tracking-wider">${esc(name)}</h3>
|
||||||
|
<table class="shortcut-table"><tbody>`;
|
||||||
|
for (const s of shortcuts) {
|
||||||
|
const keysHtml = s.keys
|
||||||
|
? s.keys // pre-formatted HTML (kbd tags etc.)
|
||||||
|
: `<kbd>${esc(s.key || '?')}</kbd>`;
|
||||||
|
html += `<tr><td>${keysHtml}</td><td>${esc(s.label)}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += `</tbody></table></section>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
@@ -6,9 +6,18 @@ export const state = {
|
|||||||
// Data
|
// Data
|
||||||
cards: [],
|
cards: [],
|
||||||
buckets: [],
|
buckets: [],
|
||||||
|
// True once we've got a real response from api/cards (or the
|
||||||
|
// cached-cards hydration). Until then the board shows a spinner
|
||||||
|
// instead of the "no cards found" placeholder — an empty board on
|
||||||
|
// first load is an honest "still fetching" state, not a real empty.
|
||||||
|
cardsLoaded: false,
|
||||||
|
|
||||||
// Filters & view
|
// Filters & view — activeBucket is persisted across reloads via
|
||||||
activeBucket: 'all',
|
// localStorage. Numeric ids are stringified; the special filters
|
||||||
|
// ('all', 'current', 'favorites', 'journal', 'none') are stored
|
||||||
|
// as-is. Validity is re-checked after buckets load — see
|
||||||
|
// ensureActiveBucketValid() below.
|
||||||
|
activeBucket: localStorage.getItem('hyprcrd-active-bucket') || 'all',
|
||||||
activeTags: [],
|
activeTags: [],
|
||||||
activeGroup: null,
|
activeGroup: null,
|
||||||
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
||||||
@@ -23,6 +32,21 @@ export const state = {
|
|||||||
|
|
||||||
// Multi-select
|
// Multi-select
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
|
// True when at least one bulk gesture (rubber-band) participated in
|
||||||
|
// building the current selection. Used by the join/merge action to
|
||||||
|
// pick the title strategy: false → first selected wins, true →
|
||||||
|
// longest title wins (since "first" isn't meaningful for bulk).
|
||||||
|
selectionWasBulk: false,
|
||||||
|
// Card ids that have arrived since the user last entered the "all"
|
||||||
|
// view. Drives the unread dot on the all bucket-tab and the brief
|
||||||
|
// accent pulse on the cards themselves once the user looks at them.
|
||||||
|
// Updated by board.js renderBoard() — see the diff there.
|
||||||
|
unseenCardIds: new Set(),
|
||||||
|
// Ids of cards the user just created locally (editor save, quick
|
||||||
|
// capture, duplicate, undo-recreate). These get a single-pulse (1x)
|
||||||
|
// instead of the triple-pulse (3x) that relay-synced cards get.
|
||||||
|
// Consumed by renderBoard's diff — once used, the id is removed.
|
||||||
|
recentlyCreatedIds: new Set(),
|
||||||
|
|
||||||
// Detail / editor
|
// Detail / editor
|
||||||
editingId: null,
|
editingId: null,
|
||||||
@@ -47,6 +71,28 @@ export const state = {
|
|||||||
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
||||||
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
||||||
|
|
||||||
|
const SPECIAL_BUCKETS = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||||
|
|
||||||
|
/** Set state.activeBucket and persist it to localStorage so it survives
|
||||||
|
* page reloads. Use this instead of `state.activeBucket = …` everywhere
|
||||||
|
* except tests (tests reset directly to avoid touching localStorage). */
|
||||||
|
export function setActiveBucket(value) {
|
||||||
|
state.activeBucket = value;
|
||||||
|
if (value == null) localStorage.removeItem('hyprcrd-active-bucket');
|
||||||
|
else localStorage.setItem('hyprcrd-active-bucket', String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from loadBuckets after the fresh bucket list arrives. If the
|
||||||
|
* persisted activeBucket points to a bucket id that no longer exists
|
||||||
|
* (deleted from another tab, removed in import, etc.), fall back to
|
||||||
|
* 'all'. Special filters are always valid. */
|
||||||
|
export function ensureActiveBucketValid() {
|
||||||
|
const cur = state.activeBucket;
|
||||||
|
if (!cur || SPECIAL_BUCKETS.has(cur)) return;
|
||||||
|
const exists = state.buckets.some(b => String(b.id) === String(cur));
|
||||||
|
if (!exists) setActiveBucket('all');
|
||||||
|
}
|
||||||
|
|
||||||
export const CARD_COLORS = [
|
export const CARD_COLORS = [
|
||||||
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
||||||
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// ── Sync Orchestration ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Owns the single source of truth for "time to sync". Every scheduled
|
||||||
|
// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
|
||||||
|
// the chat plugin — hangs off the same interval via the `sync.tick` hook
|
||||||
|
// instead of running its own setInterval. That way:
|
||||||
|
//
|
||||||
|
// - The header Sync button triggers ALL syncs (emits `sync.tick`).
|
||||||
|
// - The Autosync toggle pauses ALL syncs (stops the single timer).
|
||||||
|
// - The configurable interval changes ALL cadences at once.
|
||||||
|
//
|
||||||
|
// This module also owns the autosync button UI, the sync-event toast
|
||||||
|
// feed, and the relay settings panel (which hosts the interval input).
|
||||||
|
//
|
||||||
|
// Hooks emitted:
|
||||||
|
// sync.tick — fire all pollers now
|
||||||
|
// sync.pulse — pulse the header dot (subset of tick)
|
||||||
|
// sync.interval.changed — restart timers with new cadence
|
||||||
|
// autosync.changed — user paused/resumed scheduled polling
|
||||||
|
//
|
||||||
|
// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
|
||||||
|
// in caldav.js. This file has no CalDAV icons/colors concerns.
|
||||||
|
|
||||||
|
import { $, api, getCsrf } from './util.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
const syncStatus = $('caldav-sync-status');
|
||||||
|
|
||||||
|
// ── Manual "Sync now" trigger ───────────────────────────────────
|
||||||
|
// Fired by the header Sync button, the CalDAV panel "sync now" button,
|
||||||
|
// and the `autoSync()` throttled helper. Always runs regardless of the
|
||||||
|
// autosync-paused setting — the user pressed a button, we honor that.
|
||||||
|
// Fans out to every plugin poller via `sync.tick`.
|
||||||
|
export async function triggerSync() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
const btn = $('cal-sync');
|
||||||
|
if (btn) btn.style.opacity = '0.5';
|
||||||
|
const headerIcon = $('header-sync-icon');
|
||||||
|
if (headerIcon) headerIcon.classList.add('card-spinner');
|
||||||
|
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||||
|
|
||||||
|
const res = await fetch('api/caldav/sync', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
||||||
|
|
||||||
|
if (btn) btn.style.opacity = '';
|
||||||
|
|
||||||
|
// Also poll relay for mail-style messages. Header spinner stays on
|
||||||
|
// until this completes too.
|
||||||
|
const relayRes = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
|
||||||
|
// Fan out to plugin pollers (collab-buckets, chat, …). They run
|
||||||
|
// concurrently with us — no await needed here.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const s = res.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const caldavStats = { pushed: s.pushed || 0, pulled: s.pulled || 0, deleted: s.deleted || 0 };
|
||||||
|
const relayStats = { pulled: rp };
|
||||||
|
const allLog = res.log || [];
|
||||||
|
const hasErrors = allLog.some(l => l.status === 'error');
|
||||||
|
const msg = `pushed:${caldavStats.pushed} pulled:${caldavStats.pulled + rp} del:${caldavStats.deleted}${rp ? ' relay:' + rp : ''}`;
|
||||||
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showLog(allLog);
|
||||||
|
showSyncNotification('CalDAV', 'ph-cloud-arrow-down', caldavStats);
|
||||||
|
showSyncNotification('Relay', 'ph-broadcast', relayStats);
|
||||||
|
} else {
|
||||||
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||||
|
}
|
||||||
|
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV log display inside the Settings → CalDAV panel. Lives here
|
||||||
|
// (not in caldav.js) because triggerSync writes to it.
|
||||||
|
function showLog(log) {
|
||||||
|
const logEl = $('caldav-log');
|
||||||
|
if (!logEl || !log?.length) return;
|
||||||
|
logEl.classList.remove('hidden');
|
||||||
|
logEl.innerHTML = log.map(l =>
|
||||||
|
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-sync throttle ──────────────────────────────────────────
|
||||||
|
// Used by the CalDAV module when the user saves a card with #termin or
|
||||||
|
// #geburtstag — we want to fire a sync immediately but not storm the
|
||||||
|
// server if they paste a big batch. 60s cooldown between auto-fires.
|
||||||
|
let lastAutoSync = 0;
|
||||||
|
export function autoSync() {
|
||||||
|
if (window._testRunning) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAutoSync < 60000) return;
|
||||||
|
lastAutoSync = now;
|
||||||
|
triggerSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync notifications ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Per-source sync toast. Caller picks the label ("CalDAV" vs "Relay")
|
||||||
|
* and icon so the user knows which pipeline just finished, instead of
|
||||||
|
* the old always-CalDAV banner that lumped relay traffic in silently. */
|
||||||
|
let syncNoteOffset = 0;
|
||||||
|
function showSyncNotification(label, icon, stats) {
|
||||||
|
const s = stats || {};
|
||||||
|
const parts = [];
|
||||||
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
||||||
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
||||||
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
||||||
|
if (!parts.length) return;
|
||||||
|
|
||||||
|
const bottom = 60 + syncNoteOffset * 48;
|
||||||
|
syncNoteOffset++;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;`;
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ${icon}"></i>${label}: ${parts.join(', ')}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => { note.remove(); syncNoteOffset = Math.max(0, syncNoteOffset - 1); }, 350);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One toast per incoming sync event: "alice synced: Card Title".
|
||||||
|
// Stacks downward so a burst of multiple doesn't overlay each other.
|
||||||
|
let syncEventToastOffset = 0;
|
||||||
|
function showSyncEventToast(ev) {
|
||||||
|
const body = String(ev?.body || '').trim();
|
||||||
|
if (!body) return;
|
||||||
|
const bottom = 110 + syncEventToastOffset * 48;
|
||||||
|
syncEventToastOffset++;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
note.remove();
|
||||||
|
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
|
||||||
|
}, 350);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainSyncEvents(relayRes) {
|
||||||
|
if (relayRes?.sync_debug) {
|
||||||
|
console.debug('[sync-events]', relayRes.sync_debug);
|
||||||
|
}
|
||||||
|
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
|
||||||
|
for (const ev of events) showSyncEventToast(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduled background pull ───────────────────────────────────
|
||||||
|
// Internal helper — the CalDAV + relay mail side of a tick. Plugin
|
||||||
|
// pollers run concurrently via the `sync.tick` hook.
|
||||||
|
async function backgroundPull() {
|
||||||
|
if (state.bgSyncRunning) return;
|
||||||
|
state.bgSyncRunning = true;
|
||||||
|
try {
|
||||||
|
const headers = { 'X-CSRF-Token': getCsrf() };
|
||||||
|
const [res, relayRes] = await Promise.all([
|
||||||
|
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
]);
|
||||||
|
const s = res?.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
||||||
|
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showSyncNotification(total);
|
||||||
|
}
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
} catch {} finally {
|
||||||
|
state.bgSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central tick: the one and only scheduled sync heartbeat. Fires the
|
||||||
|
// CalDAV+relay pull and emits `sync.tick` for every other poller.
|
||||||
|
function tick() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
backgroundPull();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configurable sync interval ────────────────────────────────
|
||||||
|
// One setting drives every scheduled poller. Changed via the Relay
|
||||||
|
// settings panel; emits `sync.interval.changed` so the central timer
|
||||||
|
// restarts on the new cadence without a page reload.
|
||||||
|
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
|
||||||
|
const SYNC_INTERVAL_DEFAULT = 15;
|
||||||
|
const SYNC_INTERVAL_MIN = 5;
|
||||||
|
const SYNC_INTERVAL_MAX = 3600;
|
||||||
|
export function getSyncIntervalSec() {
|
||||||
|
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
|
||||||
|
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
|
||||||
|
}
|
||||||
|
export function setSyncIntervalSec(sec) {
|
||||||
|
const n = parseInt(sec);
|
||||||
|
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
|
||||||
|
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autosync on/off toggle ────────────────────────────────────
|
||||||
|
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
|
||||||
|
export function isAutoSyncEnabled() {
|
||||||
|
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
|
||||||
|
}
|
||||||
|
export function setAutoSyncEnabled(on) {
|
||||||
|
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
|
||||||
|
if (on) startBackgroundSync();
|
||||||
|
else stopBackgroundSync();
|
||||||
|
updateAutosyncButton();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central tick timer ────────────────────────────────────────
|
||||||
|
let tickInterval = null;
|
||||||
|
export function startBackgroundSync() {
|
||||||
|
if (tickInterval) return;
|
||||||
|
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
|
||||||
|
tick();
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
export function stopBackgroundSync() {
|
||||||
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the central timer when the user changes the interval.
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Header autosync button ────────────────────────────────────
|
||||||
|
function updateAutosyncButton() {
|
||||||
|
const btn = $('header-autosync');
|
||||||
|
if (!btn) return;
|
||||||
|
const on = isAutoSyncEnabled();
|
||||||
|
btn.dataset.autosync = on ? 'on' : 'off';
|
||||||
|
btn.setAttribute('data-tip', on
|
||||||
|
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
|
||||||
|
: 'auto-sync: paused — click to resume');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse the dot once. Rip and re-add the class after a reflow so
|
||||||
|
// re-triggering mid-animation restarts cleanly.
|
||||||
|
export function pulseAutosyncDot() {
|
||||||
|
const dot = $('header-autosync-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.remove('pulsing');
|
||||||
|
void dot.offsetWidth;
|
||||||
|
dot.classList.add('pulsing');
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
|
||||||
|
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
|
||||||
|
updateAutosyncButton();
|
||||||
|
|
||||||
|
// ── Sync-adjacent button wiring ───────────────────────────────
|
||||||
|
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('header-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||||
|
|
||||||
|
// ── Relay settings panel ──────────────────────────────────────
|
||||||
|
// The relay panel lives in Settings → Relay. It holds the URL /
|
||||||
|
// username / shared-key inputs plus the shared sync-interval number
|
||||||
|
// input that drives every scheduled poller.
|
||||||
|
|
||||||
|
function setRelayStatus(text, kind = 'info') {
|
||||||
|
const el = $('relay-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.borderColor = '';
|
||||||
|
el.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.textContent = text;
|
||||||
|
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
||||||
|
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
||||||
|
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('relay-save')?.addEventListener('click', async () => {
|
||||||
|
const body = {
|
||||||
|
url: $('relay-url')?.value || '',
|
||||||
|
username: $('relay-username')?.value || '',
|
||||||
|
key: $('relay-key')?.value || '',
|
||||||
|
};
|
||||||
|
setRelayStatus('saving...');
|
||||||
|
await api('api/relay/settings', { method: 'POST', body });
|
||||||
|
setRelayStatus('saved', 'success');
|
||||||
|
setTimeout(() => setRelayStatus(''), 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('relay-test')?.addEventListener('click', async () => {
|
||||||
|
setRelayStatus('testing...');
|
||||||
|
const res = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||||
|
body: JSON.stringify({ strict: true }),
|
||||||
|
}).then(r => r.json()).catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
||||||
|
if (res.pulled) await window._reloadAll?.();
|
||||||
|
drainSyncEvents(res);
|
||||||
|
} else {
|
||||||
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
||||||
|
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadRelaySettings() {
|
||||||
|
const s = await api('api/relay/settings');
|
||||||
|
if (!s) return;
|
||||||
|
const urlInput = $('relay-url');
|
||||||
|
const userInput = $('relay-username');
|
||||||
|
const keyInput = $('relay-key');
|
||||||
|
if (urlInput) urlInput.value = s.url || '';
|
||||||
|
if (userInput) userInput.value = s.username || '';
|
||||||
|
if (keyInput) {
|
||||||
|
// The server no longer echoes the stored secret. Leave the field
|
||||||
|
// blank; placeholder tells the user whether one is set. An empty
|
||||||
|
// save is treated as "field untouched" server-side.
|
||||||
|
keyInput.value = '';
|
||||||
|
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
|
||||||
|
}
|
||||||
|
// Sync-interval slider lives in the Relay panel but drives every
|
||||||
|
// poller. Snap points span 5s..1h; live label updates on drag,
|
||||||
|
// persisted value commits on release.
|
||||||
|
const intervalInp = $('setting-sync-interval');
|
||||||
|
const intervalVal = $('setting-sync-interval-val');
|
||||||
|
if (intervalInp) {
|
||||||
|
const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
|
||||||
|
const fmt = (s) => {
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s === 3600) return '1h';
|
||||||
|
const m = Math.floor(s / 60), r = s % 60;
|
||||||
|
return r ? `${m}m ${r}s` : `${m}m`;
|
||||||
|
};
|
||||||
|
const idxFor = (sec) => {
|
||||||
|
let best = 0, bestDiff = Infinity;
|
||||||
|
for (let i = 0; i < STEPS.length; i++) {
|
||||||
|
const d = Math.abs(STEPS[i] - sec);
|
||||||
|
if (d < bestDiff) { bestDiff = d; best = i; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
intervalInp.max = String(STEPS.length - 1);
|
||||||
|
const sync = (idx) => {
|
||||||
|
const sec = STEPS[idx];
|
||||||
|
if (intervalVal) intervalVal.textContent = fmt(sec);
|
||||||
|
return sec;
|
||||||
|
};
|
||||||
|
intervalInp.value = String(idxFor(getSyncIntervalSec()));
|
||||||
|
sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.oninput = () => sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.onchange = () => {
|
||||||
|
const sec = sync(parseInt(intervalInp.value));
|
||||||
|
setSyncIntervalSec(sec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,13 @@ const summary = $('tasks-summary');
|
|||||||
const countBadge = $('tasks-open-count');
|
const countBadge = $('tasks-open-count');
|
||||||
|
|
||||||
function collectTasks() {
|
function collectTasks() {
|
||||||
|
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
|
||||||
const all = [];
|
const all = [];
|
||||||
for (const card of state.cards) {
|
for (const card of state.cards) {
|
||||||
|
if (!showShopping) {
|
||||||
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||||
|
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
|
||||||
|
}
|
||||||
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
||||||
let m, idx = 0;
|
let m, idx = 0;
|
||||||
while ((m = re.exec(card.body || '')) !== null) {
|
while ((m = re.exec(card.body || '')) !== null) {
|
||||||
|
|||||||
+327
-5
@@ -10,7 +10,7 @@ const themeList = $('settings-theme-list');
|
|||||||
// Render theme list in settings
|
// Render theme list in settings
|
||||||
function renderThemes() {
|
function renderThemes() {
|
||||||
if (!themeList) return;
|
if (!themeList) return;
|
||||||
const current = localStorage.getItem('hyprcrd-theme') || 'slate';
|
const current = (designPending?.theme) || localStorage.getItem('hyprcrd-theme') || 'slate';
|
||||||
themeList.innerHTML = '';
|
themeList.innerHTML = '';
|
||||||
for (const [key, t] of Object.entries(window.themes)) {
|
for (const [key, t] of Object.entries(window.themes)) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
@@ -19,7 +19,16 @@ function renderThemes() {
|
|||||||
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
|
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
|
||||||
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
|
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
|
||||||
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
|
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
|
||||||
btn.onclick = () => { window.applyTheme(key); renderThemes(); window._renderBoard?.(); };
|
btn.onclick = () => {
|
||||||
|
window.applyTheme(key);
|
||||||
|
if (designPending) {
|
||||||
|
designPending.theme = key;
|
||||||
|
window.saveDesign(designPending);
|
||||||
|
flashSaved();
|
||||||
|
}
|
||||||
|
renderThemes();
|
||||||
|
window._renderBoard?.();
|
||||||
|
};
|
||||||
themeList.appendChild(btn);
|
themeList.appendChild(btn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,9 +45,314 @@ overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
|||||||
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
||||||
if (panel === 'notifications') renderNotifSettings();
|
if (panel === 'notifications') renderNotifSettings();
|
||||||
if (panel === 'behavior') initBehaviorSettings();
|
if (panel === 'behavior') initBehaviorSettings();
|
||||||
|
if (panel === 'theme') initDesignSettings();
|
||||||
|
if (panel === 'plugins') window._openPluginSettings?.();
|
||||||
|
if (panel !== 'theme') setPeekUI(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Design (fonts, geometry, borders, spacing, effects, presets) ──
|
||||||
|
let designPending = null;
|
||||||
|
|
||||||
|
const SLIDER_FIELDS = [
|
||||||
|
['setting-radius-card', 'radiusCard', 'val-radius-card'],
|
||||||
|
['setting-radius-button', 'radiusButton', 'val-radius-button'],
|
||||||
|
['setting-radius-input', 'radiusInput', 'val-radius-input'],
|
||||||
|
['setting-radius-modal', 'radiusModal', 'val-radius-modal'],
|
||||||
|
['setting-radius-tag', 'radiusTag', 'val-radius-tag'],
|
||||||
|
['setting-radius-image', 'radiusImage', 'val-radius-image'],
|
||||||
|
['setting-border-card', 'borderCard', 'val-border-card'],
|
||||||
|
['setting-bar-height', 'barHeight', 'val-bar-height'],
|
||||||
|
['setting-pad-card', 'padCard', 'val-pad-card'],
|
||||||
|
['setting-gap-board', 'gapBoard', 'val-gap-board'],
|
||||||
|
['setting-lift-card', 'liftCard', 'val-lift-card'],
|
||||||
|
['setting-blur-overlay', 'blurOverlay', 'val-blur-overlay'],
|
||||||
|
];
|
||||||
|
const SELECT_FIELDS = [
|
||||||
|
['setting-font-headline', 'fontHeadline'],
|
||||||
|
['setting-font-content', 'fontContent'],
|
||||||
|
['setting-font-ui', 'fontUi'],
|
||||||
|
['setting-border-style', 'borderStyle'],
|
||||||
|
['setting-shadow-card', 'shadowCard'],
|
||||||
|
['setting-shadow-modal', 'shadowModal'],
|
||||||
|
['setting-shadow-ui', 'shadowUi'],
|
||||||
|
['setting-anim-speed', 'animSpeed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const preview = $('theme-preview');
|
||||||
|
if (!preview || !designPending) return;
|
||||||
|
const d = designPending, fonts = window.themeFonts, shadows = window.designShadows;
|
||||||
|
const set = (k, v) => preview.style.setProperty(k, v);
|
||||||
|
set('--tp-font-headline', fonts[d.fontHeadline] || fonts.pixel);
|
||||||
|
set('--tp-font-content', fonts[d.fontContent] || fonts.mono);
|
||||||
|
set('--tp-font-ui', fonts[d.fontUi] || fonts.pixel);
|
||||||
|
set('--tp-radius-card', d.radiusCard + 'px');
|
||||||
|
set('--tp-radius-button', d.radiusButton + 'px');
|
||||||
|
set('--tp-radius-input', d.radiusInput + 'px');
|
||||||
|
set('--tp-radius-tag', d.radiusTag + 'px');
|
||||||
|
set('--tp-border-card', d.borderCard + 'px');
|
||||||
|
set('--tp-border-style', d.borderStyle);
|
||||||
|
set('--tp-bar-height', d.barHeight + 'px');
|
||||||
|
set('--tp-pad-card', d.padCard + 'px');
|
||||||
|
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFontSelect(id, value) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '';
|
||||||
|
for (const [key, label] of Object.entries(window.themeFontLabels)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = key; opt.textContent = label;
|
||||||
|
if (key === value) opt.selected = true;
|
||||||
|
el.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControlsFromPending() {
|
||||||
|
if (!designPending) return;
|
||||||
|
fillFontSelect('setting-font-headline', designPending.fontHeadline);
|
||||||
|
fillFontSelect('setting-font-content', designPending.fontContent);
|
||||||
|
fillFontSelect('setting-font-ui', designPending.fontUi);
|
||||||
|
for (const [id, key] of SELECT_FIELDS) {
|
||||||
|
if (id.startsWith('setting-font-')) continue;
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.value = String(designPending[key] ?? '');
|
||||||
|
}
|
||||||
|
for (const [id, key, valId] of SLIDER_FIELDS) {
|
||||||
|
const v = String(designPending[key] ?? 0);
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.value = v;
|
||||||
|
const valEl = $(valId);
|
||||||
|
if (valEl) valEl.textContent = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDesign() {
|
||||||
|
updatePreview();
|
||||||
|
window.applyDesign(designPending);
|
||||||
|
window.saveDesign(designPending);
|
||||||
|
flashSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveFlashTimer = null;
|
||||||
|
function flashSaved() {
|
||||||
|
const status = $('design-status');
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = 'saved';
|
||||||
|
status.style.color = 'var(--t-accent)';
|
||||||
|
clearTimeout(saveFlashTimer);
|
||||||
|
saveFlashTimer = setTimeout(() => {
|
||||||
|
status.textContent = 'auto-saving';
|
||||||
|
status.style.color = 'var(--t-dim)';
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
let designWired = false;
|
||||||
|
|
||||||
|
function initDesignSettings() {
|
||||||
|
designPending = window.loadDesign();
|
||||||
|
syncControlsFromPending();
|
||||||
|
updatePreview();
|
||||||
|
refreshPresetSelect();
|
||||||
|
|
||||||
|
const status = $('design-status');
|
||||||
|
if (status) { status.textContent = 'auto-saving'; status.style.color = 'var(--t-dim)'; }
|
||||||
|
|
||||||
|
if (designWired) return;
|
||||||
|
designWired = true;
|
||||||
|
|
||||||
|
// Selects
|
||||||
|
for (const [id, key] of SELECT_FIELDS) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) continue;
|
||||||
|
el.addEventListener('change', () => {
|
||||||
|
designPending[key] = el.value;
|
||||||
|
commitDesign();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sliders
|
||||||
|
for (const [id, key, valId] of SLIDER_FIELDS) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) continue;
|
||||||
|
el.addEventListener('input', () => {
|
||||||
|
const num = Number(el.value);
|
||||||
|
designPending[key] = num;
|
||||||
|
const v = $(valId);
|
||||||
|
if (v) v.textContent = num;
|
||||||
|
commitDesign();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('design-reset')?.addEventListener('click', () => {
|
||||||
|
designPending = { ...window.designDefaults };
|
||||||
|
syncControlsFromPending();
|
||||||
|
commitDesign();
|
||||||
|
renderThemes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Peek toggle (hide background dim so the real board is visible behind the popup) ──
|
||||||
|
function setPeekUI(peeking) {
|
||||||
|
document.body.classList.toggle('settings-peeking', peeking);
|
||||||
|
const sw = $('design-peek-toggle');
|
||||||
|
if (sw) sw.classList.toggle('on', peeking);
|
||||||
|
}
|
||||||
|
const peekRow = $('design-peek-row');
|
||||||
|
if (peekRow) peekRow.onclick = () => {
|
||||||
|
setPeekUI(!document.body.classList.contains('settings-peeking'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Presets ──
|
||||||
|
|
||||||
|
function loadUserPresets() {
|
||||||
|
try { return JSON.parse(localStorage.getItem('hyprcrd-design-presets') || '{}'); }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
function saveUserPresets(p) {
|
||||||
|
localStorage.setItem('hyprcrd-design-presets', JSON.stringify(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPresetSelect() {
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (!sel) return;
|
||||||
|
// localStorage wins over sel.value: the <select> survives settings
|
||||||
|
// close/reopen, so its lingering value would otherwise mask the
|
||||||
|
// persisted selection on the next refresh.
|
||||||
|
const cur = localStorage.getItem('hyprcrd-design-active-preset') || sel.value || '';
|
||||||
|
sel.innerHTML = '';
|
||||||
|
const builtinGroup = document.createElement('optgroup');
|
||||||
|
builtinGroup.label = 'built-in';
|
||||||
|
for (const name of Object.keys(window.builtinPresets || {})) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = 'builtin:' + name; opt.textContent = name;
|
||||||
|
builtinGroup.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.appendChild(builtinGroup);
|
||||||
|
const user = loadUserPresets();
|
||||||
|
const names = Object.keys(user);
|
||||||
|
if (names.length) {
|
||||||
|
const userGroup = document.createElement('optgroup');
|
||||||
|
userGroup.label = 'custom';
|
||||||
|
for (const name of names) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = 'user:' + name; opt.textContent = name;
|
||||||
|
userGroup.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.appendChild(userGroup);
|
||||||
|
}
|
||||||
|
if (cur && sel.querySelector(`option[value="${CSS.escape(cur)}"]`)) sel.value = cur;
|
||||||
|
// Disable delete button when a built-in preset is selected
|
||||||
|
const delBtn = $('design-preset-delete');
|
||||||
|
if (delBtn) {
|
||||||
|
const isUser = sel.value?.startsWith('user:');
|
||||||
|
delBtn.disabled = !isUser;
|
||||||
|
delBtn.style.opacity = isUser ? '' : '0.35';
|
||||||
|
delBtn.style.cursor = isUser ? '' : 'not-allowed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPreset() {
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (!sel?.value) return null;
|
||||||
|
const [kind, ...rest] = sel.value.split(':');
|
||||||
|
const name = rest.join(':');
|
||||||
|
if (kind === 'builtin') return window.builtinPresets?.[name] ? { ...window.builtinPresets[name] } : null;
|
||||||
|
if (kind === 'user') return loadUserPresets()[name] ? { ...loadUserPresets()[name] } : null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSelectedPreset() {
|
||||||
|
const cfg = getSelectedPreset();
|
||||||
|
if (!cfg) return;
|
||||||
|
designPending = { ...window.designDefaults, ...cfg };
|
||||||
|
syncControlsFromPending();
|
||||||
|
commitDesign();
|
||||||
|
renderThemes();
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (sel?.value) localStorage.setItem('hyprcrd-design-active-preset', sel.value);
|
||||||
|
}
|
||||||
|
$('design-preset-select')?.addEventListener('change', loadSelectedPreset);
|
||||||
|
|
||||||
|
$('design-preset-save')?.addEventListener('click', () => {
|
||||||
|
const nameEl = $('design-preset-name');
|
||||||
|
const name = (nameEl?.value || '').trim();
|
||||||
|
if (!name) { nameEl?.focus(); return; }
|
||||||
|
const presets = loadUserPresets();
|
||||||
|
presets[name] = { ...designPending };
|
||||||
|
saveUserPresets(presets);
|
||||||
|
if (nameEl) nameEl.value = '';
|
||||||
|
refreshPresetSelect();
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (sel) sel.value = 'user:' + name;
|
||||||
|
localStorage.setItem('hyprcrd-design-active-preset', 'user:' + name);
|
||||||
|
const status = $('design-status');
|
||||||
|
if (status) { status.textContent = `preset "${name}" saved`; status.style.color = 'var(--t-accent)'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('design-preset-delete')?.addEventListener('click', () => {
|
||||||
|
const sel = $('design-preset-select');
|
||||||
|
if (!sel?.value?.startsWith('user:')) return;
|
||||||
|
const name = sel.value.slice('user:'.length);
|
||||||
|
const presets = loadUserPresets();
|
||||||
|
delete presets[name];
|
||||||
|
saveUserPresets(presets);
|
||||||
|
if (localStorage.getItem('hyprcrd-design-active-preset') === 'user:' + name) {
|
||||||
|
localStorage.removeItem('hyprcrd-design-active-preset');
|
||||||
|
}
|
||||||
|
refreshPresetSelect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('design-preset-export')?.addEventListener('click', async () => {
|
||||||
|
const cfg = designPending || window.loadDesign();
|
||||||
|
const json = JSON.stringify(cfg, null, 2);
|
||||||
|
// Best-effort clipboard (silently fails on non-secure contexts)
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(json);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Always also trigger a file download — works regardless of clipboard support
|
||||||
|
try {
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'hyprcrd-design.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('design export failed', e);
|
||||||
|
}
|
||||||
|
const status = $('design-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = copied ? 'downloaded + copied to clipboard' : 'downloaded hyprcrd-design.json';
|
||||||
|
status.style.color = 'var(--t-accent)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('design-preset-import')?.addEventListener('click', async () => {
|
||||||
|
const { showDialog } = await import('./dialog.js');
|
||||||
|
const json = await showDialog({ title: 'Import design JSON', input: true, placeholder: '{"radiusCard":8,...}', okLabel: 'Import' });
|
||||||
|
if (!json) return;
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(json);
|
||||||
|
designPending = { ...window.designDefaults, ...cfg };
|
||||||
|
syncControlsFromPending();
|
||||||
|
commitDesign();
|
||||||
|
renderThemes();
|
||||||
|
} catch {
|
||||||
|
const status = $('design-status');
|
||||||
|
if (status) { status.textContent = 'invalid JSON'; status.style.color = 'var(--t-danger)'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Open/Close
|
// Open/Close
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
|
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
|
||||||
@@ -76,12 +390,17 @@ async function loadVaultStatus() {
|
|||||||
|
|
||||||
$('settings-btn')?.addEventListener('click', () => {
|
$('settings-btn')?.addEventListener('click', () => {
|
||||||
renderThemes();
|
renderThemes();
|
||||||
|
initDesignSettings();
|
||||||
updateStats();
|
updateStats();
|
||||||
loadVaultStatus();
|
loadVaultStatus();
|
||||||
overlay?.classList.add('open');
|
overlay?.classList.add('open');
|
||||||
});
|
});
|
||||||
$('settings-close')?.addEventListener('click', () => overlay?.classList.remove('open'));
|
function closeSettings() {
|
||||||
overlay?.addEventListener('click', e => { if (e.target === overlay) overlay?.classList.remove('open'); });
|
overlay?.classList.remove('open');
|
||||||
|
setPeekUI(false);
|
||||||
|
}
|
||||||
|
$('settings-close')?.addEventListener('click', closeSettings);
|
||||||
|
overlay?.addEventListener('click', e => { if (e.target === overlay) closeSettings(); });
|
||||||
|
|
||||||
// Password change
|
// Password change
|
||||||
$('settings-pw-save')?.addEventListener('click', async () => {
|
$('settings-pw-save')?.addEventListener('click', async () => {
|
||||||
@@ -92,7 +411,7 @@ $('settings-pw-save')?.addEventListener('click', async () => {
|
|||||||
|
|
||||||
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw.length < 10) { if (status) { status.textContent = 'min 10 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
|
|
||||||
const res = await fetch('api/auth/password', {
|
const res = await fetch('api/auth/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -122,6 +441,7 @@ function initBehaviorSettings() {
|
|||||||
'setting-card-width': { key: 'hyprcrd-card-width', def: '280' },
|
'setting-card-width': { key: 'hyprcrd-card-width', def: '280' },
|
||||||
'setting-body-lines': { key: 'hyprcrd-body-lines', def: '5' },
|
'setting-body-lines': { key: 'hyprcrd-body-lines', def: '5' },
|
||||||
'setting-click-action': { key: 'hyprcrd-click-action', def: 'preview' },
|
'setting-click-action': { key: 'hyprcrd-click-action', def: 'preview' },
|
||||||
|
'setting-esc-action': { key: 'hyprcrd-esc-action', def: 'close' },
|
||||||
};
|
};
|
||||||
const toggles = {
|
const toggles = {
|
||||||
'setting-current-include-synced': { key: 'hyprcrd-current-include-synced', def: '0' },
|
'setting-current-include-synced': { key: 'hyprcrd-current-include-synced', def: '0' },
|
||||||
@@ -130,6 +450,8 @@ function initBehaviorSettings() {
|
|||||||
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
||||||
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
||||||
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
||||||
|
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
|
||||||
|
'setting-tasks-show-shopping': { key: 'hyprcrd-tasks-show-shopping', def: '0' },
|
||||||
};
|
};
|
||||||
for (const [id, cfg] of Object.entries(selects)) {
|
for (const [id, cfg] of Object.entries(selects)) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// ── 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 MAX_LIFE = 5000; // watchdog: tooltip never outlives this
|
||||||
|
const OFFSET = 8;
|
||||||
|
|
||||||
|
let tipEl = null;
|
||||||
|
let showTimer = null;
|
||||||
|
let hideTimer = null;
|
||||||
|
let lifeTimer = 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 = '';
|
||||||
|
|
||||||
|
// Watchdog: if pointerleave never fires (fast flick off the
|
||||||
|
// element, target removed from the DOM mid-hover, focus taken by
|
||||||
|
// another window), the tooltip would sit forever. Force-hide
|
||||||
|
// after MAX_LIFE regardless.
|
||||||
|
clearTimeout(lifeTimer);
|
||||||
|
lifeTimer = setTimeout(hide, MAX_LIFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
currentTarget = null;
|
||||||
|
clearTimeout(lifeTimer);
|
||||||
|
if (tipEl) tipEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter(e) {
|
||||||
|
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
|
||||||
|
const target = el?.closest?.('[data-tip],[data-help]');
|
||||||
|
if (!target) return;
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
showTimer = setTimeout(() => show(target), SHOW_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave(e) {
|
||||||
|
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
|
||||||
|
const target = el?.closest?.('[data-tip],[data-help]');
|
||||||
|
if (!target) return;
|
||||||
|
clearTimeout(showTimer);
|
||||||
|
hideTimer = setTimeout(hide, HIDE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerenter', onEnter, true);
|
||||||
|
document.addEventListener('pointerleave', onLeave, true);
|
||||||
|
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
|
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
|
document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
|
|
||||||
|
// Belt-and-suspenders sanity check on every move: if the pointer is
|
||||||
|
// no longer over the tip-owning target (or the target has been
|
||||||
|
// detached from the DOM since show time), hide. Catches fast flicks
|
||||||
|
// that skip the expected pointerleave and DOM churn that yanks the
|
||||||
|
// element out from under us.
|
||||||
|
document.addEventListener('pointermove', (e) => {
|
||||||
|
if (!currentTarget) return;
|
||||||
|
if (!currentTarget.isConnected) { hide(); return; }
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
if (!el || !currentTarget.contains(el)) hide();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Window losing focus / tab hidden — nothing to hover over while
|
||||||
|
// we're not there, kill any lingering tip.
|
||||||
|
window.addEventListener('blur', () => { clearTimeout(showTimer); hide(); });
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) { clearTimeout(showTimer); hide(); } });
|
||||||
@@ -66,21 +66,29 @@ export function pushBatch(label, patches) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toast hint
|
// Toast hint
|
||||||
|
//
|
||||||
|
// Centering note: tailwind v4 compiles `-translate-x-1/2` to the *new*
|
||||||
|
// CSS `translate` property, which then stacks with any inline `transform`
|
||||||
|
// we set — the box ended up shifted ~ -100% (utility translate + JS
|
||||||
|
// transform translate both applied) and looked left-anchored. Fix: drop
|
||||||
|
// the tailwind translate utility and centre purely via the modern
|
||||||
|
// `translate` property in inline styles, which is one source of truth.
|
||||||
let hintTimer = null;
|
let hintTimer = null;
|
||||||
function showHint(text) {
|
function showHint(text) {
|
||||||
let el = document.getElementById('undo-hint');
|
let el = document.getElementById('undo-hint');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
el = document.createElement('div');
|
el = document.createElement('div');
|
||||||
el.id = 'undo-hint';
|
el.id = 'undo-hint';
|
||||||
el.className = 'fixed bottom-16 left-1/2 -translate-x-1/2 z-[95] bg-t-surface border border-t-border px-4 py-2 text-xs font-pixel text-t-muted shadow-lg transition-all';
|
el.className = 'fixed bottom-16 left-1/2 z-[95] bg-t-surface border border-t-border px-4 py-2 text-xs font-pixel text-t-muted shadow-lg transition-all';
|
||||||
|
el.style.translate = '-50% 0';
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
el.textContent = text + ' (ctrl+z undo)';
|
el.textContent = text + ' (ctrl+z undo)';
|
||||||
el.style.opacity = '1';
|
el.style.opacity = '1';
|
||||||
el.style.transform = 'translateX(-50%) translateY(0)';
|
el.style.translate = '-50% 0';
|
||||||
clearTimeout(hintTimer);
|
clearTimeout(hintTimer);
|
||||||
hintTimer = setTimeout(() => {
|
hintTimer = setTimeout(() => {
|
||||||
el.style.opacity = '0';
|
el.style.opacity = '0';
|
||||||
el.style.transform = 'translateX(-50%) translateY(8px)';
|
el.style.translate = '-50% 8px';
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,23 @@ export function setCsrf(token) { csrfToken = token; }
|
|||||||
export function getCsrf() { return csrfToken; }
|
export function getCsrf() { return csrfToken; }
|
||||||
|
|
||||||
export async function api(path, opts = {}) {
|
export async function api(path, opts = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const doFetch = () => {
|
||||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
const res = await fetch(path, {
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
headers,
|
return fetch(path, {
|
||||||
...opts,
|
headers,
|
||||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
...opts,
|
||||||
});
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
// Session expired mid-use: prompt for re-login, then retry once. Avoid
|
||||||
|
// recursion on auth endpoints themselves — promptLogin handles its own
|
||||||
|
// fetches directly.
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
|
||||||
|
|
||||||
describe('Board Rendering', () => {
|
|
||||||
it('renders card elements', () => {
|
|
||||||
expect($$('#board [data-id]').length).toBeGreaterThan(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cards are draggable', () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
expect(card.getAttribute('draggable')).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cards have color bar', () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
expect(card.querySelector('.card-bar')).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cards have valid data-id', () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
expect(parseInt(card.dataset.id)).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Board Split Preview', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('opens split panel', async () => {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#board-preview').classList.contains('hidden')).toBe(false);
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows empty state initially', async () => {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#board-preview-empty').classList.contains('hidden')).toBe(false);
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows card on click', async () => {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(150);
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(250);
|
|
||||||
expect($('#board-preview-title').textContent.length).toBeGreaterThan(0);
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('highlights active card', async () => {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(150);
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(250);
|
|
||||||
expect(card.classList.contains('preview-active')).toBe(true);
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Board: Card Create & Delete', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('new card appears on board', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_appear__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
expect(document.querySelector(`[data-id="${res.id}"]`)).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deleted card gone from board', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_gone__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(100);
|
|
||||||
|
|
||||||
await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
expect(document.querySelector(`[data-id="${res.id}"]`)).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deleted card clears split preview', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_split_clear__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
// Open split, select the card
|
|
||||||
if ($('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(150);
|
|
||||||
}
|
|
||||||
const el = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
if (el) click(el);
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
// Delete and check preview resets
|
|
||||||
await fetch(`api/cards/${res.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
expect($('#board-preview-empty').classList.contains('hidden')).toBe(false);
|
|
||||||
|
|
||||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js';
|
|
||||||
import { state } from '../modules/state.js';
|
|
||||||
|
|
||||||
describe('Card: Tag Operations', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('tag chips visible on cards (if setting on)', () => {
|
|
||||||
if (localStorage.getItem('hyprcrd-show-tags') === '0') return;
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
const tags = card.querySelectorAll('.tag-chip');
|
|
||||||
// May or may not have tags, just verify no crash
|
|
||||||
expect(tags).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tag click filters board', async () => {
|
|
||||||
const seg = document.querySelector('#board .tag-seg[data-tag]');
|
|
||||||
if (!seg) return;
|
|
||||||
const tag = seg.dataset.tag;
|
|
||||||
seg.click();
|
|
||||||
await sleep(200);
|
|
||||||
expect(state.activeTags).toContain(tag);
|
|
||||||
// Reset
|
|
||||||
seg.click();
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tag right-click removes tag from card', async () => {
|
|
||||||
// Create a card with a tag
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_tag_rm__', body: 'text #testtag123', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
const tagSeg = cardEl?.querySelector('.tag-seg[data-tag="testtag123"]');
|
|
||||||
if (tagSeg) {
|
|
||||||
tagSeg.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, preventDefault: true }));
|
|
||||||
await sleep(300);
|
|
||||||
// Verify tag removed
|
|
||||||
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
|
||||||
const card = updated.find(c => c.id == res.id);
|
|
||||||
expect(card.tags).toBeFalsy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Card: Due Date & Done', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('card with due_date shows due pill', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_due_pill__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await fetch(`api/cards/${res.id}`, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ due_date: '2099-12-31' }),
|
|
||||||
});
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
const pill = cardEl?.querySelector('.due-pill');
|
|
||||||
expect(pill).toExist();
|
|
||||||
expect(pill.textContent).toContain('2099-12-31');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('card with #done tag shows done badge', async () => {
|
|
||||||
if (localStorage.getItem('hyprcrd-show-done-badge') === '0') return;
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_done_badge__', body: '#done', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
const badge = cardEl?.querySelector('.card-done-badge');
|
|
||||||
expect(badge).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('overdue card has danger border', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_overdue__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await fetch(`api/cards/${res.id}`, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ due_date: '2020-01-01' }),
|
|
||||||
});
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
expect(cardEl).toExist();
|
|
||||||
// Overdue cards get danger border via inline style
|
|
||||||
expect(cardEl.style.borderBottom).toContain('var(--t-danger)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Card: Context Menu Actions', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('favorite toggles', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(150);
|
|
||||||
const favBtn = $$('#ctx-items [data-action="favorite"]')[0];
|
|
||||||
const wasFav = favBtn?.textContent.includes('unfavorite');
|
|
||||||
click(favBtn);
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
// Open menu again to check toggled state
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(150);
|
|
||||||
const favBtn2 = $$('#ctx-items [data-action="favorite"]')[0];
|
|
||||||
const isFav = favBtn2?.textContent.includes('unfavorite');
|
|
||||||
expect(isFav).toBe(!wasFav);
|
|
||||||
// Toggle back
|
|
||||||
click(favBtn2);
|
|
||||||
await sleep(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('color submenu changes card color', async () => {
|
|
||||||
const res = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_color_ctx__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
||||||
if (!cardEl) return;
|
|
||||||
rightClick(cardEl);
|
|
||||||
await sleep(150);
|
|
||||||
// Click color action to open submenu
|
|
||||||
click($$('#ctx-items [data-action="color"]')[0]);
|
|
||||||
await sleep(100);
|
|
||||||
// Pick a different color
|
|
||||||
const colorBtn = document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]');
|
|
||||||
if (colorBtn) {
|
|
||||||
click(colorBtn);
|
|
||||||
await sleep(300);
|
|
||||||
const updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
|
||||||
expect(updated.find(c => c.id == res.id).color).toBe('#fb7185');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Card: Bucket Assignment', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('card moves to bucket via context menu', async () => {
|
|
||||||
// Create bucket and card
|
|
||||||
const bucket = await fetch('api/buckets', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ name: '__test_ctx_bucket__' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
|
|
||||||
const card = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_ctx_move__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const cardEl = document.querySelector(`[data-id="${card.id}"]`);
|
|
||||||
if (!cardEl) return;
|
|
||||||
rightClick(cardEl);
|
|
||||||
await sleep(150);
|
|
||||||
// Click bucket action
|
|
||||||
click($$('#ctx-items [data-action="bucket"]')[0]);
|
|
||||||
await sleep(100);
|
|
||||||
// Click the test bucket in submenu
|
|
||||||
const bucketBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id);
|
|
||||||
if (bucketBtn) {
|
|
||||||
click(bucketBtn);
|
|
||||||
await sleep(300);
|
|
||||||
const updated = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
|
||||||
expect(updated.find(c => c.id == card.id).bucket_id).toBe(bucket.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Card: Grouping', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('cards can be grouped via API', async () => {
|
|
||||||
const parent = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_parent__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
const child = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_child__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
|
|
||||||
await fetch(`api/cards/${child.id}`, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ parent_id: parent.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const all = await fetch(`api/cards?ids=${child.id}`).then(r => r.json());
|
|
||||||
expect(all.find(c => c.id == child.id).parent_id).toBe(parent.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('child card hidden on board (shown under parent)', async () => {
|
|
||||||
const parent = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_grp_parent__', body: '', color: '#38bdf8' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
const child = await fetch('api/cards', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
||||||
body: JSON.stringify({ title: '__test_grp_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
// Child should not be a standalone card on board
|
|
||||||
const childEl = document.querySelector(`[data-id="${child.id}"]`);
|
|
||||||
// It may or may not render depending on group view, but parent should exist
|
|
||||||
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
|
||||||
expect(parentEl).toExist();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, rightClick, sleep } from './runner.js';
|
|
||||||
|
|
||||||
describe('Context Menu', () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
document.dispatchEvent(new MouseEvent('click'));
|
|
||||||
await sleep(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens on card right-click', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#ctx-menu').classList.contains('hidden')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has all standard actions', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(150);
|
|
||||||
const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action);
|
|
||||||
for (const a of ['open', 'edit', 'duplicate', 'due-date', 'save-md', 'push-relay', 'favorite', 'color', 'bucket', 'tags', 'delete']) {
|
|
||||||
expect(actions).toContain(a);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has color submenu with presets', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(100);
|
|
||||||
const colors = $$('#ctx-color-grid [data-ctx-color]');
|
|
||||||
expect(colors.length).toBeGreaterThan(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has tag submenu with input', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(100);
|
|
||||||
expect($('#ctx-tag-input')).toExist();
|
|
||||||
expect($('#ctx-tag-list')).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes on document click', async () => {
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
rightClick(card);
|
|
||||||
await sleep(100);
|
|
||||||
document.dispatchEvent(new MouseEvent('click'));
|
|
||||||
await sleep(50);
|
|
||||||
expect($('#ctx-menu').classList.contains('hidden')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js';
|
|
||||||
|
|
||||||
describe('Detail View', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('has structural elements', () => {
|
|
||||||
for (const id of ['detail', 'detail-title', 'detail-body', 'detail-meta', 'detail-tags']) {
|
|
||||||
expect($(`#${id}`)).toExist();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has control buttons', () => {
|
|
||||||
for (const id of ['detail-edit', 'detail-delete', 'detail-done', 'detail-zen', 'detail-close']) {
|
|
||||||
expect($(`#${id}`)).toExist();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens on card click (non-split)', async () => {
|
|
||||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(300);
|
|
||||||
expect($('#detail')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows card title', async () => {
|
|
||||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(300);
|
|
||||||
expect($('#detail-title').textContent.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes on ESC', async () => {
|
|
||||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(300);
|
|
||||||
pressKey('Escape');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#detail')).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes on close button', async () => {
|
|
||||||
if (!$('#board-preview')?.classList.contains('hidden')) {
|
|
||||||
click('#board-split-toggle');
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
const card = $('#board [data-id]');
|
|
||||||
if (!card) return;
|
|
||||||
click(card);
|
|
||||||
await sleep(300);
|
|
||||||
click('#detail-close');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#detail')).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
// ── hyprcrd GUI Test Runner ──
|
|
||||||
// Self-contained in-app test framework. No external dependencies.
|
|
||||||
// Activate: ?test=1 in URL or Ctrl+Shift+T
|
|
||||||
// Panel is hidden during test run so interactions are visible.
|
|
||||||
// Backup is created before and auto-restored after every run.
|
|
||||||
|
|
||||||
import { getCsrf } from '../modules/util.js';
|
|
||||||
import { stopBackgroundSync, startBackgroundSync } from '../modules/caldav.js';
|
|
||||||
|
|
||||||
const suites = [];
|
|
||||||
let currentSuite = null;
|
|
||||||
|
|
||||||
// ── Public API ──
|
|
||||||
|
|
||||||
export function describe(name, fn) {
|
|
||||||
const suite = { name, tests: [], beforeEach: null, afterEach: null };
|
|
||||||
currentSuite = suite;
|
|
||||||
suites.push(suite);
|
|
||||||
fn();
|
|
||||||
currentSuite = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function it(name, fn) {
|
|
||||||
if (!currentSuite) throw new Error('it() must be inside describe()');
|
|
||||||
currentSuite.tests.push({ name, fn });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function beforeEach(fn) {
|
|
||||||
if (currentSuite) currentSuite.beforeEach = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function afterEach(fn) {
|
|
||||||
if (currentSuite) currentSuite.afterEach = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Assertions ──
|
|
||||||
|
|
||||||
export function expect(actual) {
|
|
||||||
return {
|
|
||||||
toBe(expected) {
|
|
||||||
if (actual !== expected) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
|
||||||
},
|
|
||||||
toEqual(expected) {
|
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new AssertionError(`expected ${json(expected)}, got ${json(actual)}`);
|
|
||||||
},
|
|
||||||
toBeTruthy() {
|
|
||||||
if (!actual) throw new AssertionError(`expected truthy, got ${json(actual)}`);
|
|
||||||
},
|
|
||||||
toBeFalsy() {
|
|
||||||
if (actual) throw new AssertionError(`expected falsy, got ${json(actual)}`);
|
|
||||||
},
|
|
||||||
toContain(item) {
|
|
||||||
if (typeof actual === 'string') {
|
|
||||||
if (!actual.includes(item)) throw new AssertionError(`expected "${trunc(actual)}" to contain "${item}"`);
|
|
||||||
} else if (Array.isArray(actual)) {
|
|
||||||
if (!actual.includes(item)) throw new AssertionError(`expected array to contain ${json(item)}`);
|
|
||||||
} else {
|
|
||||||
throw new AssertionError('toContain requires string or array');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toBeGreaterThan(n) {
|
|
||||||
if (!(actual > n)) throw new AssertionError(`expected ${actual} > ${n}`);
|
|
||||||
},
|
|
||||||
toBeLessThan(n) {
|
|
||||||
if (!(actual < n)) throw new AssertionError(`expected ${actual} < ${n}`);
|
|
||||||
},
|
|
||||||
toMatch(regex) {
|
|
||||||
if (!regex.test(actual)) throw new AssertionError(`expected "${trunc(actual)}" to match ${regex}`);
|
|
||||||
},
|
|
||||||
toExist() {
|
|
||||||
if (actual === null || actual === undefined) throw new AssertionError('expected to exist, got ' + json(actual));
|
|
||||||
},
|
|
||||||
toBeVisible() {
|
|
||||||
if (!actual) throw new AssertionError('element is null');
|
|
||||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
|
||||||
if (!el) throw new AssertionError('element not found');
|
|
||||||
if (!isVisible(el)) throw new AssertionError('expected element to be visible');
|
|
||||||
},
|
|
||||||
toBeHidden() {
|
|
||||||
if (!actual) return; // null = hidden
|
|
||||||
const el = typeof actual === 'string' ? document.querySelector(actual) : actual;
|
|
||||||
if (!el) return;
|
|
||||||
if (isVisible(el)) throw new AssertionError('expected element to be hidden');
|
|
||||||
},
|
|
||||||
toHaveCount(n) {
|
|
||||||
const list = typeof actual === 'string' ? document.querySelectorAll(actual) : actual;
|
|
||||||
const len = list?.length ?? 0;
|
|
||||||
if (len !== n) throw new AssertionError(`expected ${n} elements, found ${len}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssertionError extends Error {
|
|
||||||
constructor(msg) { super(msg); this.name = 'AssertionError'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(v) { try { return JSON.stringify(v); } catch { return String(v); } }
|
|
||||||
function trunc(s) { return String(s).length > 80 ? String(s).substring(0, 80) + '...' : String(s); }
|
|
||||||
|
|
||||||
function isVisible(el) {
|
|
||||||
if (!el) return false;
|
|
||||||
if (el.classList.contains('overlay')) return el.classList.contains('open');
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return r.width > 0 && r.height > 0 && !el.classList.contains('hidden') && getComputedStyle(el).display !== 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM Helpers ──
|
|
||||||
|
|
||||||
export function $(selector) {
|
|
||||||
return document.querySelector(selector) || document.getElementById(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function $$(selector) {
|
|
||||||
return [...document.querySelectorAll(selector)];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function click(selectorOrEl) {
|
|
||||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
|
||||||
if (!el) throw new Error(`click: element not found: ${selectorOrEl}`);
|
|
||||||
el.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dblclick(selectorOrEl) {
|
|
||||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
|
||||||
if (!el) throw new Error(`dblclick: element not found: ${selectorOrEl}`);
|
|
||||||
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function type(selectorOrEl, text) {
|
|
||||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
|
||||||
if (!el) throw new Error(`type: element not found: ${selectorOrEl}`);
|
|
||||||
el.focus();
|
|
||||||
el.value = text;
|
|
||||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pressKey(key, opts = {}) {
|
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...opts }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rightClick(selectorOrEl, x, y) {
|
|
||||||
const el = typeof selectorOrEl === 'string' ? $(selectorOrEl) : selectorOrEl;
|
|
||||||
if (!el) throw new Error(`rightClick: element not found: ${selectorOrEl}`);
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
el.dispatchEvent(new MouseEvent('contextmenu', {
|
|
||||||
bubbles: true, clientX: x ?? rect.left + 10, clientY: y ?? rect.top + 10,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitFor(fn, timeout = 3000) {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
try { const r = fn(); if (r) return r; } catch {}
|
|
||||||
await sleep(50);
|
|
||||||
}
|
|
||||||
throw new Error(`waitFor timed out after ${timeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sleep(ms) {
|
|
||||||
return new Promise(r => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function csrf() {
|
|
||||||
return getCsrf();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeAll() {
|
|
||||||
for (const id of ['settings-overlay', 'help', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks']) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el?.classList.contains('open')) el.classList.remove('open');
|
|
||||||
}
|
|
||||||
document.querySelector('#ctx-menu')?.classList.add('hidden');
|
|
||||||
document.querySelector('#select-bar')?.classList.add('hidden');
|
|
||||||
document.querySelector('.bucket-picker')?.remove();
|
|
||||||
document.querySelector('.orphan-lightbox')?.remove();
|
|
||||||
// Clear multi-select
|
|
||||||
document.querySelectorAll('.card-selected').forEach(el => {
|
|
||||||
el.classList.remove('card-selected');
|
|
||||||
el.querySelector('.card-select-check')?.remove();
|
|
||||||
});
|
|
||||||
await sleep(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Backup & Restore ──
|
|
||||||
|
|
||||||
let backupBlob = null;
|
|
||||||
|
|
||||||
async function createBackup() {
|
|
||||||
const res = await fetch('api/export');
|
|
||||||
if (!res.ok) throw new Error('backup failed: ' + res.status);
|
|
||||||
backupBlob = await res.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreBackup() {
|
|
||||||
if (!backupBlob) return;
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', backupBlob, 'backup.zip');
|
|
||||||
await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Runner ──
|
|
||||||
|
|
||||||
async function runAll() {
|
|
||||||
const results = [];
|
|
||||||
for (const suite of suites) {
|
|
||||||
const suiteResult = { name: suite.name, tests: [] };
|
|
||||||
for (const test of suite.tests) {
|
|
||||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
|
||||||
const t0 = performance.now();
|
|
||||||
try {
|
|
||||||
if (suite.beforeEach) await suite.beforeEach();
|
|
||||||
await test.fn();
|
|
||||||
if (suite.afterEach) await suite.afterEach();
|
|
||||||
} catch (err) {
|
|
||||||
entry.status = 'fail';
|
|
||||||
entry.error = err.message;
|
|
||||||
// Always run afterEach on failure too
|
|
||||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
|
||||||
}
|
|
||||||
entry.time = Math.round(performance.now() - t0);
|
|
||||||
suiteResult.tests.push(entry);
|
|
||||||
}
|
|
||||||
results.push(suiteResult);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI Panel ──
|
|
||||||
|
|
||||||
let panel = null;
|
|
||||||
|
|
||||||
function createPanel() {
|
|
||||||
if (panel) { panel.remove(); panel = null; }
|
|
||||||
panel = document.createElement('div');
|
|
||||||
panel.id = 'test-panel';
|
|
||||||
panel.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(2,6,23,0.95);display:flex;flex-direction:column;font-family:monospace;color:#cbd5e1;overflow:hidden;';
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div style="padding:12px 16px;border-bottom:1px solid #334155;display:flex;align-items:center;gap:12px;flex-shrink:0;">
|
|
||||||
<span style="font-weight:bold;color:#38bdf8;">hyprcrd test runner</span>
|
|
||||||
<button id="test-run-btn" style="padding:4px 12px;background:#38bdf8;color:#0f172a;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">run all</button>
|
|
||||||
<button id="test-copy-btn" style="padding:4px 12px;background:#334155;color:#94a3b8;border:none;border-radius:3px;cursor:pointer;font-family:inherit;font-size:12px;">copy</button>
|
|
||||||
<span id="test-summary" style="font-size:12px;color:#64748b;"></span>
|
|
||||||
<span style="flex:1"></span>
|
|
||||||
<button id="test-close-btn" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px;">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="test-output" style="flex:1;overflow-y:scroll;padding:12px 16px;font-size:12px;line-height:1.8;"></div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(panel);
|
|
||||||
panel.querySelector('#test-close-btn').addEventListener('click', close);
|
|
||||||
panel.querySelector('#test-run-btn').addEventListener('click', run);
|
|
||||||
panel.querySelector('#test-copy-btn').addEventListener('click', () => {
|
|
||||||
const output = panel.querySelector('#test-output');
|
|
||||||
const text = output.innerText;
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
const btn = panel.querySelector('#test-copy-btn');
|
|
||||||
btn.textContent = 'copied';
|
|
||||||
btn.style.color = '#4ade80';
|
|
||||||
setTimeout(() => { btn.textContent = 'copy'; btn.style.color = '#94a3b8'; }, 1500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderResults(results) {
|
|
||||||
const output = panel.querySelector('#test-output');
|
|
||||||
const summary = panel.querySelector('#test-summary');
|
|
||||||
let total = 0, passed = 0, failed = 0, totalTime = 0;
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
for (const suite of results) {
|
|
||||||
html += `<div style="margin-bottom:16px;">`;
|
|
||||||
html += `<div style="color:#94a3b8;font-weight:bold;margin-bottom:4px;">${esc(suite.name)}</div>`;
|
|
||||||
for (const t of suite.tests) {
|
|
||||||
total++;
|
|
||||||
totalTime += t.time;
|
|
||||||
if (t.status === 'pass') {
|
|
||||||
passed++;
|
|
||||||
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
|
||||||
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
}
|
|
||||||
output.innerHTML = html;
|
|
||||||
summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`;
|
|
||||||
summary.style.color = failed ? '#fb7185' : '#4ade80';
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const output = panel.querySelector('#test-output');
|
|
||||||
const summary = panel.querySelector('#test-summary');
|
|
||||||
|
|
||||||
// 1. Create backup
|
|
||||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
|
||||||
summary.textContent = '';
|
|
||||||
await sleep(50);
|
|
||||||
try {
|
|
||||||
await createBackup();
|
|
||||||
} catch (e) {
|
|
||||||
output.innerHTML = `<div style="color:#fb7185;">backup failed: ${esc(e.message)}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Stop background sync so tests don't create side effects
|
|
||||||
stopBackgroundSync();
|
|
||||||
window._testRunning = true;
|
|
||||||
|
|
||||||
// 3. Hide panel so tests run against visible UI
|
|
||||||
panel.style.display = 'none';
|
|
||||||
await sleep(100);
|
|
||||||
|
|
||||||
// 4. Run tests
|
|
||||||
const results = await runAll();
|
|
||||||
|
|
||||||
// 5. Clean up any leftover UI state
|
|
||||||
await closeAll();
|
|
||||||
|
|
||||||
// 6. Restore backup
|
|
||||||
try {
|
|
||||||
await restoreBackup();
|
|
||||||
await window._reloadAll?.();
|
|
||||||
await sleep(200);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
|
|
||||||
window._testRunning = false;
|
|
||||||
setTimeout(() => startBackgroundSync(), 60000);
|
|
||||||
|
|
||||||
// 8. Show panel with results
|
|
||||||
panel.style.display = '';
|
|
||||||
renderResults(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (panel) { panel.remove(); panel = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function open() {
|
|
||||||
createPanel();
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-activate ──
|
|
||||||
|
|
||||||
if (new URLSearchParams(location.search).has('test')) {
|
|
||||||
setTimeout(open, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
|
||||||
e.preventDefault();
|
|
||||||
panel ? close() : open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, type, pressKey, sleep, closeAll } from './runner.js';
|
|
||||||
|
|
||||||
describe('Search', () => {
|
|
||||||
afterEach(() => closeAll());
|
|
||||||
|
|
||||||
it('opens with Ctrl+K', async () => {
|
|
||||||
pressKey('k', { ctrlKey: true });
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#search')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens with search button', async () => {
|
|
||||||
click('#search-btn');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#search')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input is focused on open', async () => {
|
|
||||||
click('#search-btn');
|
|
||||||
await sleep(150);
|
|
||||||
expect(document.activeElement).toBe($('#search-input'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has input and results area', async () => {
|
|
||||||
click('#search-btn');
|
|
||||||
await sleep(100);
|
|
||||||
expect($('#search-input')).toExist();
|
|
||||||
expect($('#search-results')).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('typing triggers results render', async () => {
|
|
||||||
click('#search-btn');
|
|
||||||
await sleep(100);
|
|
||||||
type('#search-input', 'a');
|
|
||||||
await sleep(250);
|
|
||||||
expect($('#search-results')).toExist();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes with ESC', async () => {
|
|
||||||
click('#search-btn');
|
|
||||||
await sleep(100);
|
|
||||||
pressKey('Escape');
|
|
||||||
await sleep(150);
|
|
||||||
expect($('#search')).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// ── hyprcrd Test Index ──
|
|
||||||
// Imports all test suites. Add new test files here.
|
|
||||||
|
|
||||||
import './shell.test.js';
|
|
||||||
import './settings.test.js';
|
|
||||||
import './board.test.js';
|
|
||||||
import './toolbar.test.js';
|
|
||||||
import './buckets.test.js';
|
|
||||||
import './context-menu.test.js';
|
|
||||||
import './card-interactions.test.js';
|
|
||||||
import './filtering.test.js';
|
|
||||||
import './sorting.test.js';
|
|
||||||
import './search.test.js';
|
|
||||||
import './search-logic.test.js';
|
|
||||||
import './calendar.test.js';
|
|
||||||
import './calendar-popup.test.js';
|
|
||||||
import './datepicker.test.js';
|
|
||||||
import './gallery.test.js';
|
|
||||||
import './help.test.js';
|
|
||||||
import './multiselect.test.js';
|
|
||||||
import './detail.test.js';
|
|
||||||
import './detail-interactions.test.js';
|
|
||||||
import './editor.test.js';
|
|
||||||
import './editor-features.test.js';
|
|
||||||
import './editor-save.test.js';
|
|
||||||
import './snippets.test.js';
|
|
||||||
import './image-fullscreen.test.js';
|
|
||||||
import './markdown.test.js';
|
|
||||||
import './markdown-edge.test.js';
|
|
||||||
import './undo.test.js';
|
|
||||||
import './api.test.js';
|
|
||||||
import './api-edge.test.js';
|
|
||||||
import './export.test.js';
|
|
||||||
import './relay.test.js';
|
|
||||||
import './notifications.test.js';
|
|
||||||
import './theme.test.js';
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# hyprcrd GUI Test Coverage
|
|
||||||
|
|
||||||
Self-contained in-app test suite. No external dependencies.
|
|
||||||
|
|
||||||
## Activation
|
|
||||||
|
|
||||||
- **URL**: append `?test=1`
|
|
||||||
- **Keyboard**: `Ctrl+Shift+T`
|
|
||||||
- **Safety**: automatic backup before run, auto-restore after
|
|
||||||
- **Visibility**: panel hides during tests so you can watch interactions, shows results after
|
|
||||||
|
|
||||||
## Test Files
|
|
||||||
|
|
||||||
| File | Suites | Tests | Focus |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `shell.test.js` | 1 | 7 | Header, footer, toolbar, file inputs |
|
|
||||||
| `settings.test.js` | 6 | 11 | All 7 tabs, open/close, account, behavior, CalDAV, relay, notifications |
|
|
||||||
| `board.test.js` | 3 | 11 | Card rendering, split preview, card lifecycle |
|
|
||||||
| `toolbar.test.js` | 2 | 7 | Filter toggles (done/termine/tagged), superview, sort modes |
|
|
||||||
| `buckets.test.js` | 2 | 8 | Bucket bar, filtering, create/delete appearance |
|
|
||||||
| `context-menu.test.js` | 1 | 5 | Right-click, actions, submenus, close |
|
|
||||||
| `card-interactions.test.js` | 4 | 10 | Tags, due dates, done badges, favorites, color, bucket, grouping |
|
|
||||||
| `search.test.js` | 1 | 6 | Open (Ctrl+K, button), input focus, typing, ESC |
|
|
||||||
| `calendar.test.js` | 1 | 7 | Open, month title, navigation, today, close |
|
|
||||||
| `gallery.test.js` | 1 | 5 | Open, image count, select-none, close |
|
|
||||||
| `help.test.js` | 1 | 4 | F1 toggle, button, content sections |
|
|
||||||
| `multiselect.test.js` | 1 | 5 | Ctrl+click select/deselect, multiple, click-outside clear |
|
|
||||||
| `detail.test.js` | 1 | 6 | Structure, controls, open, title, ESC, close |
|
|
||||||
| `detail-interactions.test.js` | 4 | 4 | Done toggle, edit from detail, zen mode, backlinks |
|
|
||||||
| `editor.test.js` | 3 | 8 | Structure, open/close, card create |
|
|
||||||
| `editor-features.test.js` | 5 | 7 | Color picker, preview, line numbers, save flow, edit existing |
|
|
||||||
| `api.test.js` | 3 | 10 | Cards CRUD, buckets CRUD, auth & misc endpoints |
|
|
||||||
| `relay.test.js` | 2 | 3 | Push API, poll API (unconfigured state) |
|
|
||||||
| `notifications.test.js` | 2 | 4 | Banner element, localStorage, settings selects |
|
|
||||||
| `theme.test.js` | 2 | 6 | CSS vars, localStorage, applyTheme, themes object |
|
|
||||||
|
|
||||||
**Total: 21 files, ~45 suites, ~150 tests**
|
|
||||||
|
|
||||||
## Coverage by Area
|
|
||||||
|
|
||||||
### App Shell
|
|
||||||
- [x] Header with logo
|
|
||||||
- [x] Board element (relative positioning)
|
|
||||||
- [x] All footer buttons (logout, import, export, logseq, vault, settings)
|
|
||||||
- [x] All header buttons (search, calendar, gallery, add)
|
|
||||||
- [x] Toolbar controls (split, superview, hide-done, hide-termine, hide-tagged, sort)
|
|
||||||
- [x] Footer card count
|
|
||||||
- [x] Hidden file inputs
|
|
||||||
|
|
||||||
### Toolbar Toggles
|
|
||||||
- [x] hide-done toggles state and `.active` class
|
|
||||||
- [x] hide-termine toggles state and `.active` class
|
|
||||||
- [x] hide-tagged toggles state and `.active` class
|
|
||||||
- [x] superview toggles state and `.active` class
|
|
||||||
- [x] sort cycles through all modes (manual, newest, oldest, alpha, tag)
|
|
||||||
- [x] superview renders list items
|
|
||||||
|
|
||||||
### Bucket Bar
|
|
||||||
- [x] Renders bucket tabs
|
|
||||||
- [x] "all" bucket exists
|
|
||||||
- [x] Clicking bucket filters cards
|
|
||||||
- [x] "current" bucket works
|
|
||||||
- [x] "journal" bucket works
|
|
||||||
- [x] "unsorted" bucket works
|
|
||||||
- [x] New bucket appears in bar
|
|
||||||
- [x] Deleted bucket disappears from bar
|
|
||||||
|
|
||||||
### Settings Panel
|
|
||||||
- [x] Opens on button click
|
|
||||||
- [x] All 7 tabs exist (branding, account, theme, caldav, relay, behavior, notifications)
|
|
||||||
- [x] Tab switching shows correct panels
|
|
||||||
- [x] Closes on X button
|
|
||||||
- [x] Closes on overlay background click
|
|
||||||
- [x] Account: password fields exist and masked
|
|
||||||
- [x] Account: vault key field
|
|
||||||
- [x] Behavior: all selects (current-days, card-height, card-width, body-lines, click-action)
|
|
||||||
- [x] Behavior: all toggles (show-tags, show-thumbnails, show-done-badge, confirm-delete, auto-save)
|
|
||||||
- [x] CalDAV: connection fields (url, user, pass, test, save, sync-now)
|
|
||||||
- [x] Relay: fields (url, key, save, test)
|
|
||||||
- [x] Notifications: lookahead, time controls
|
|
||||||
|
|
||||||
### Board
|
|
||||||
- [x] Cards render with data-id
|
|
||||||
- [x] Cards are draggable
|
|
||||||
- [x] Cards have color bar
|
|
||||||
- [x] Valid data-id attributes
|
|
||||||
- [x] Split preview opens/closes
|
|
||||||
- [x] Split preview empty state
|
|
||||||
- [x] Split preview shows card on click
|
|
||||||
- [x] Active card highlighted in split mode
|
|
||||||
- [x] New card appears on board after create
|
|
||||||
- [x] Deleted card gone from board
|
|
||||||
- [x] Deleted card clears split preview
|
|
||||||
|
|
||||||
### Card Interactions
|
|
||||||
- [x] Tag chips visible (if setting on)
|
|
||||||
- [x] Tag click filters board
|
|
||||||
- [x] Tag right-click removes tag
|
|
||||||
- [x] Due date shows due pill
|
|
||||||
- [x] Done tag shows done badge
|
|
||||||
- [x] Overdue card has danger border
|
|
||||||
- [x] Favorite toggles via context menu
|
|
||||||
- [x] Color change via context menu
|
|
||||||
- [x] Bucket assignment via context menu
|
|
||||||
- [x] Cards can be grouped (parent_id)
|
|
||||||
- [x] Child card hidden under parent on board
|
|
||||||
|
|
||||||
### Context Menu
|
|
||||||
- [x] Opens on right-click
|
|
||||||
- [x] All actions present (open, edit, duplicate, due-date, save-md, push-relay, favorite, color, bucket, tags, delete)
|
|
||||||
- [x] Color submenu with presets
|
|
||||||
- [x] Tag submenu with input
|
|
||||||
- [x] Closes on document click
|
|
||||||
|
|
||||||
### Search
|
|
||||||
- [x] Opens with Ctrl+K
|
|
||||||
- [x] Opens with button
|
|
||||||
- [x] Input focused on open
|
|
||||||
- [x] Input and results area exist
|
|
||||||
- [x] Typing triggers results
|
|
||||||
- [x] Closes with ESC
|
|
||||||
|
|
||||||
### Calendar
|
|
||||||
- [x] Button exists
|
|
||||||
- [x] Opens on click
|
|
||||||
- [x] Month title displayed
|
|
||||||
- [x] Navigation buttons (prev, next, today)
|
|
||||||
- [x] Month navigation works
|
|
||||||
- [x] Today resets to current month
|
|
||||||
- [x] Closes on close button
|
|
||||||
|
|
||||||
### Gallery
|
|
||||||
- [x] Button exists
|
|
||||||
- [x] Opens on click
|
|
||||||
- [x] Grid has user-select: none
|
|
||||||
- [x] Image count shown
|
|
||||||
- [x] Closes on close button
|
|
||||||
|
|
||||||
### Help
|
|
||||||
- [x] Opens with F1
|
|
||||||
- [x] Toggles with F1
|
|
||||||
- [x] Opens with button
|
|
||||||
- [x] Has content sections
|
|
||||||
|
|
||||||
### Multi-Select
|
|
||||||
- [x] Select bar hidden by default
|
|
||||||
- [x] All action buttons (group, bucket, unbucket, color, tag, export, delete, clear)
|
|
||||||
- [x] Ctrl+click selects card
|
|
||||||
- [x] Ctrl+click deselects card
|
|
||||||
- [x] Multiple cards selectable with count
|
|
||||||
- [x] Click outside clears selection
|
|
||||||
|
|
||||||
### Detail View
|
|
||||||
- [x] Structural elements (title, body, meta, tags)
|
|
||||||
- [x] Control buttons (edit, delete, done, zen, close)
|
|
||||||
- [x] Opens on card click (non-split mode)
|
|
||||||
- [x] Shows card title
|
|
||||||
- [x] Closes on ESC
|
|
||||||
- [x] Closes on close button
|
|
||||||
- [x] Done button toggles #done tag
|
|
||||||
- [x] Edit button opens editor with card data
|
|
||||||
- [x] Zen mode button exists
|
|
||||||
- [x] Backlinks section exists
|
|
||||||
|
|
||||||
### Editor
|
|
||||||
- [x] Structural elements (modal, title, body, preview, color presets)
|
|
||||||
- [x] Save buttons (save, save-close, cancel)
|
|
||||||
- [x] Due date and done toggle
|
|
||||||
- [x] Opens on add button
|
|
||||||
- [x] Title focused on open
|
|
||||||
- [x] Closes on cancel
|
|
||||||
- [x] Closes on ESC
|
|
||||||
- [x] Opens on board right-click
|
|
||||||
- [x] Color presets render
|
|
||||||
- [x] Color preset changes card color
|
|
||||||
- [x] Preview pane and toggle exist
|
|
||||||
- [x] Line numbers gutter and toggle exist
|
|
||||||
- [x] Full save flow (type title+body → save-close → card exists)
|
|
||||||
- [x] Edit existing card loads data
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
- [x] GET /api/cards — returns array
|
|
||||||
- [x] GET /api/cards/meta — returns array
|
|
||||||
- [x] POST /api/cards — creates card
|
|
||||||
- [x] PATCH /api/cards/:id — updates (returns card object)
|
|
||||||
- [x] DELETE /api/cards/:id — deletes (returns {ok})
|
|
||||||
- [x] Tag extraction from body
|
|
||||||
- [x] Due date persistence
|
|
||||||
- [x] Bucket assignment
|
|
||||||
- [x] GET /api/buckets — returns array
|
|
||||||
- [x] POST /api/buckets — creates bucket
|
|
||||||
- [x] PATCH /api/buckets/:id — renames (returns bucket object)
|
|
||||||
- [x] DELETE /api/buckets/:id — deletes (returns {ok})
|
|
||||||
- [x] GET /api/auth/status — logged in
|
|
||||||
- [x] GET /api/relay/settings — reachable
|
|
||||||
- [x] GET /api/caldav/settings — reachable
|
|
||||||
- [x] Unknown endpoint returns 404
|
|
||||||
|
|
||||||
### Relay API
|
|
||||||
- [x] Push without config returns error
|
|
||||||
- [x] Push without card_id returns 400
|
|
||||||
- [x] Poll without config returns 0 pulled
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
- [x] Banner element exists
|
|
||||||
- [x] Settings in localStorage
|
|
||||||
- [x] Lookahead select has options
|
|
||||||
- [x] Time select has options
|
|
||||||
|
|
||||||
### Theme
|
|
||||||
- [x] CSS custom properties set (bg, accent, text, surface, border, muted, dim, bright)
|
|
||||||
- [x] Theme persisted in localStorage
|
|
||||||
- [x] applyTheme function available
|
|
||||||
- [x] Themes object with 5+ themes
|
|
||||||
- [x] Card layout CSS vars
|
|
||||||
|
|
||||||
## Not Yet Covered
|
|
||||||
|
|
||||||
- Drag and drop (card reorder, bucket assignment via drag)
|
|
||||||
- Box-select rubber band
|
|
||||||
- CalDAV sync flow (requires server)
|
|
||||||
- Relay push/poll with configured relay
|
|
||||||
- File upload (paste, drag into editor)
|
|
||||||
- Image fullscreen viewer (zoom, pan)
|
|
||||||
- Zen mode resize handles
|
|
||||||
- Notification banner dismiss
|
|
||||||
- Undo (Ctrl+Z)
|
|
||||||
- Logseq import flow (requires ZIP file)
|
|
||||||
- Export download verification
|
|
||||||
- Datepicker dialog interaction
|
|
||||||
- Editor autocomplete (tags, links, snippets)
|
|
||||||
- Calendar day popup and card creation
|
|
||||||
- Search drag-and-drop to group
|
|
||||||
|
|
||||||
## Adding Tests
|
|
||||||
|
|
||||||
1. Create `yourfeature.test.js` in `public/js/test/`
|
|
||||||
2. Import runner: `import { describe, it, expect, ... } from './runner.js'`
|
|
||||||
3. Add import to `tests.js`
|
|
||||||
4. Update this file
|
|
||||||
|
|
||||||
Tests don't need manual cleanup — backup is auto-restored after every run.
|
|
||||||
Use `csrf()` for API calls. Use `closeAll()` in `afterEach` for UI state.
|
|
||||||
Sync is paused during test runs (`window._testRunning`).
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, it, expect } from './runner.js';
|
|
||||||
|
|
||||||
describe('Theme', () => {
|
|
||||||
it('CSS custom properties set', () => {
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
|
||||||
for (const prop of ['--t-bg', '--t-accent', '--t-text', '--t-surface', '--t-border', '--t-muted', '--t-dim', '--t-bright']) {
|
|
||||||
expect(style.getPropertyValue(prop).trim()).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('theme persisted in localStorage', () => {
|
|
||||||
expect(localStorage.getItem('hyprcrd-theme')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applyTheme function exists', () => {
|
|
||||||
expect(typeof window.applyTheme).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('themes object available', () => {
|
|
||||||
expect(window.themes).toExist();
|
|
||||||
expect(Object.keys(window.themes).length).toBeGreaterThan(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('card layout CSS vars set', () => {
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
|
||||||
expect(style.getPropertyValue('--card-min-h').trim()).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Keyboard Shortcuts', () => {
|
|
||||||
it('Ctrl+Shift+T bound (test runner)', () => {
|
|
||||||
// Just verify we got here — the test runner itself proves this works
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# hyprcrd plugin system — mindmap
|
||||||
|
|
||||||
|
A complete architectural reference. Don't lose state on this — every detail
|
||||||
|
here was learned the hard way during the canvas/tests/email plugin builds.
|
||||||
|
|
||||||
|
```
|
||||||
|
hyprcrd plugin system
|
||||||
|
│
|
||||||
|
├── Discovery (PHP)
|
||||||
|
│ ├── src/PluginManager.php (singleton)
|
||||||
|
│ │ ├── scan(dir) → reads each subdir's plugin.json
|
||||||
|
│ │ ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]}
|
||||||
|
│ │ ├── loadBackends() → require_once enabled backend.php files
|
||||||
|
│ │ └── isEnabled(name) → checked by cssLinks/htmlFragments/scriptTags
|
||||||
|
│ └── src/bootstrap.php (call order matters!)
|
||||||
|
│ 1. config.php
|
||||||
|
│ 2. Database.php
|
||||||
|
│ 3. PluginManager.php
|
||||||
|
│ 4. PluginManager::scan(public/plugins)
|
||||||
|
│ 5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json')
|
||||||
|
│ 6. PluginManager::loadBackends() ← AFTER loadDisabled, so disabled plugins skip backend.php too
|
||||||
|
│
|
||||||
|
├── plugin.json manifest format
|
||||||
|
│ ├── name (string, unique)
|
||||||
|
│ ├── version (string)
|
||||||
|
│ ├── description (string)
|
||||||
|
│ ├── frontend
|
||||||
|
│ │ ├── js: [...] (loaded as <script type="module">)
|
||||||
|
│ │ ├── css: [...] (loaded as <link>)
|
||||||
|
│ │ └── html: [...] (file contents inlined into body via htmlFragments())
|
||||||
|
│ └── backend (string, optional path to backend.php)
|
||||||
|
│
|
||||||
|
├── Core injection points (PHP, in index.php)
|
||||||
|
│ ├── In <head> after style.css: <?= PluginManager::instance()->cssLinks() ?>
|
||||||
|
│ ├── In #md-toolbar after extract: <?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||||
|
│ ├── Before </body>: <?= PluginManager::instance()->htmlFragments() ?>
|
||||||
|
│ ├── Then synchronously: <script src="js/hyprcrd.js"></script>
|
||||||
|
│ ├── Then plugin entries: <?= PluginManager::instance()->scriptTags() ?>
|
||||||
|
│ ├── Then core ES module: <script type="module" src="js/app.js">
|
||||||
|
│ └── Inside auth-protected API block, BEFORE first /api/cards check:
|
||||||
|
│ PluginManager::instance()->dispatchRoute($route, $method);
|
||||||
|
│
|
||||||
|
├── Frontend API: window.hyprcrd (defined in public/js/hyprcrd.js, sync-loaded)
|
||||||
|
│ │
|
||||||
|
│ ├── hooks (generic event bus)
|
||||||
|
│ │ ├── on(name, fn) register listener
|
||||||
|
│ │ ├── off(name, fn) remove
|
||||||
|
│ │ └── emit(name, data) → data listeners can mutate via return value
|
||||||
|
│ │
|
||||||
|
│ ├── editor (toolbar command registry)
|
||||||
|
│ │ ├── registerCommand(name, fn) buttons with data-md="<name>" dispatch here
|
||||||
|
│ │ ├── unregisterCommand(name)
|
||||||
|
│ │ ├── getCommand(name)
|
||||||
|
│ │ └── api ← set by editor.js AFTER its init
|
||||||
|
│ │ ├── body the textarea element
|
||||||
|
│ │ ├── insertMd(b, a, ph) inserts markdown around selection
|
||||||
|
│ │ └── updatePreview() re-renders the preview pane
|
||||||
|
│ │
|
||||||
|
│ ├── markdown (registry, applied in order)
|
||||||
|
│ │ ├── registerFencedBlock(lang, fn) ```<lang> blocks → handler returns HTML
|
||||||
|
│ │ ├── registerPreProcessor(fn) runs on source text before marked.parse()
|
||||||
|
│ │ ├── registerPostProcessor(fn) runs on HTML after marked.parse(), before DOMPurify
|
||||||
|
│ │ ├── getFencedBlocks()
|
||||||
|
│ │ ├── getPreProcessors()
|
||||||
|
│ │ └── getPostProcessors()
|
||||||
|
│ │ └── handler signature: fn(content, { esc, normalizeListBlock }) → htmlString
|
||||||
|
│ │
|
||||||
|
│ ├── settings (settings overlay tab registry)
|
||||||
|
│ │ ├── registerTab({id, label, icon, onShow})
|
||||||
|
│ │ │ ├── injects button into #settings-nav
|
||||||
|
│ │ │ ├── creates empty <div id="settings-panel-{id}"> in panels container
|
||||||
|
│ │ │ ├── attaches click handler (active state + emit hook + onShow)
|
||||||
|
│ │ │ └── icon = phosphor class like 'ph-envelope', defaults to ph-puzzle-piece
|
||||||
|
│ │ ├── open() clicks #settings-btn
|
||||||
|
│ │ └── openTab(id) opens settings + switches to that tab
|
||||||
|
│ │
|
||||||
|
│ ├── util
|
||||||
|
│ │ ├── $(idOrSel) querySelector or getElementById
|
||||||
|
│ │ ├── $$(sel) querySelectorAll → Array
|
||||||
|
│ │ ├── esc(s) HTML escape
|
||||||
|
│ │ ├── setCsrf(token) called by app.js after auth
|
||||||
|
│ │ └── csrf() read by plugins for API calls
|
||||||
|
│ │
|
||||||
|
│ ├── api(path, opts) fetch wrapper, auto X-CSRF-Token header
|
||||||
|
│ │
|
||||||
|
│ ├── dialog (themed confirm/prompt — NEVER use native confirm())
|
||||||
|
│ │ ├── show(opts) → core dialog.js fills this in after load
|
||||||
|
│ │ │ opts: {title, message, input, danger, okLabel, cancelLabel}
|
||||||
|
│ │ │ returns Promise<value|null> (null = cancelled)
|
||||||
|
│ │ └── confirm(msg, opts) convenience: returns Promise<true|null>
|
||||||
|
│ │ opts: {title, okLabel, cancelLabel, danger}
|
||||||
|
│ │
|
||||||
|
│ └── registerPlugin({name, version, description})
|
||||||
|
│ └── pushes to hyprcrd.plugins[] for the settings → plugins UI
|
||||||
|
│
|
||||||
|
├── Backend API extension (PHP, from plugin's backend.php)
|
||||||
|
│ └── PluginManager::instance()->onApiRoute(function($route, $method) {
|
||||||
|
│ if ($route === '/api/foo' && $method === 'GET') {
|
||||||
|
│ // ... must call exit() on match
|
||||||
|
│ echo json_encode([...]);
|
||||||
|
│ exit;
|
||||||
|
│ }
|
||||||
|
│ });
|
||||||
|
│ │
|
||||||
|
│ ├── handlers run in registration order, before core route handling
|
||||||
|
│ ├── pass-through if none exit → core routes get a chance
|
||||||
|
│ ├── CSRF middleware in core runs BEFORE dispatchRoute, so plugin
|
||||||
|
│ │ routes are automatically CSRF-protected for mutations
|
||||||
|
│ └── auth check also runs BEFORE → all plugin routes require login
|
||||||
|
│
|
||||||
|
├── Lifecycle / load order (CRITICAL for timing bugs)
|
||||||
|
│ 1. Browser parses HTML
|
||||||
|
│ 2. <link rel="stylesheet"> tags loaded (core + plugin CSS)
|
||||||
|
│ 3. <?= htmlFragments() ?> → plugin overlay HTML now in DOM
|
||||||
|
│ 4. <script src="js/hyprcrd.js"> → SYNCHRONOUS, window.hyprcrd defined
|
||||||
|
│ 5. <script type="module" src="plugins/.../*.js"> → DEFERRED, run after parse
|
||||||
|
│ ├── Plugin modules execute in document order
|
||||||
|
│ ├── At this point: DOM ready, window.hyprcrd ready, core editor.js NOT loaded yet
|
||||||
|
│ ├── So hyprcrd.editor.api is null until app.js runs
|
||||||
|
│ └── Plugin commands that USE editor.api should defer (only fire on user click)
|
||||||
|
│ 6. <script type="module" src="js/app.js"> → orchestrator
|
||||||
|
│ ├── imports core modules (state, theme, editor, markdown, ...)
|
||||||
|
│ ├── markdown.js top-level: registers gallery/tree/bullets via the SAME registry
|
||||||
|
│ ├── editor.js top-level: registers heading/bold/italic/etc + sets editor.api
|
||||||
|
│ └── checkAuth() → setCsrf() → window.hyprcrd.util.setCsrf() (mirrored)
|
||||||
|
│
|
||||||
|
├── Settings tab race (avoided by registerTab design)
|
||||||
|
│ ├── theme.js attaches click handlers to all .settings-tab at module load
|
||||||
|
│ ├── If plugin's tab is added BEFORE theme.js runs (default), theme.js handler ALSO attaches
|
||||||
|
│ ├── Result: both core handler AND plugin click handler fire on click
|
||||||
|
│ ├── Both manage active states identically — harmless duplicate work
|
||||||
|
│ └── Plugin's onShow is called by the plugin handler, NOT by core
|
||||||
|
│
|
||||||
|
├── Disable mechanism
|
||||||
|
│ ├── State file: data/plugins.json → {"disabled": ["canvas", ...]}
|
||||||
|
│ ├── Settings UI: settings → plugins → toggle switches
|
||||||
|
│ ├── API: GET /api/plugins, POST /api/plugins/toggle {name, enabled}
|
||||||
|
│ ├── Disabled plugins are skipped in cssLinks, htmlFragments, scriptTags, AND loadBackends
|
||||||
|
│ ├── Toggling requires page reload (already-loaded code can't unload)
|
||||||
|
│ └── Reload notice in plugins panel; dirtyAfterToggle flag persists until reload
|
||||||
|
│
|
||||||
|
├── Conventions
|
||||||
|
│ ├── Plugins NEVER import from core modules. Use window.hyprcrd globals only.
|
||||||
|
│ ├── Plugin JS is ES module (loaded as type="module") so import/export works WITHIN the plugin
|
||||||
|
│ ├── Plugin self-registration happens at module top level (no init function needed)
|
||||||
|
│ ├── Plugin DOM injection should idempotently check if element already exists
|
||||||
|
│ │ (canvas: btn.querySelector('[data-md="asciiart"]') guard)
|
||||||
|
│ ├── If a plugin needs DOM that may not exist yet (e.g. injectToolbarButton),
|
||||||
|
│ │ wrap in DOMContentLoaded check OR run immediately if document.readyState !== 'loading'
|
||||||
|
│ ├── Plugin schema migrations live in backend.php as IIFE that runs once per request
|
||||||
|
│ │ (cheap CREATE TABLE IF NOT EXISTS)
|
||||||
|
│ └── Plugin credentials → reuse vault key encryption (sha256(vault_key) substr 0..32 + AES-256-CBC)
|
||||||
|
│
|
||||||
|
├── Existing plugins
|
||||||
|
│ ├── canvas/ (~500 lines)
|
||||||
|
│ │ ├── No backend
|
||||||
|
│ │ ├── Registers ascii fenced block + asciiart editor command + toolbar btn (JS injected)
|
||||||
|
│ │ ├── Has its own overlay (canvas.html), tools, palette
|
||||||
|
│ │ └── Edit-mode: detects cursor inside ```ascii block, loads existing content
|
||||||
|
│ ├── tests/ (~40 test files + runner.js + loader.js)
|
||||||
|
│ │ ├── No backend
|
||||||
|
│ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
|
||||||
|
│ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
|
||||||
|
│ │ └── Both static AND dynamic imports use that path — sed both when moving files
|
||||||
|
│ ├── email/ (~1300 lines, biggest)
|
||||||
|
│ │ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
|
||||||
|
│ │ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
|
||||||
|
│ │ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
|
||||||
|
│ │ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
|
||||||
|
│ │ │ └── onShow rebuilds form on first show, refreshes accounts list every show
|
||||||
|
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||||
|
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||||
|
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||||
|
│ ├── logseq-import/ (~200 lines)
|
||||||
|
│ │ ├── Backend: reads ZIP, copies assets → uploads/, imports journals + pages
|
||||||
|
│ │ ├── Route: POST /api/import-logseq (multipart form, file field)
|
||||||
|
│ │ ├── Creates "import" bucket if missing; journals → blue, pages → violet
|
||||||
|
│ │ ├── Strips Logseq bullet prefixes and `key:: value` property lines
|
||||||
|
│ │ ├── Rewrites asset refs via filename → random-hex map
|
||||||
|
│ │ └── Frontend injects button + file input into footer before #settings-btn,
|
||||||
|
│ │ posts to /api/import-logseq, calls window._reloadAll() on success
|
||||||
|
│ └── slideshow/ (~500 lines)
|
||||||
|
│ ├── No backend — data stored as ```slide-image fenced block in card body
|
||||||
|
│ ├── Slideshow = parent card (start slide) + children (in position order)
|
||||||
|
│ ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render
|
||||||
|
│ ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %)
|
||||||
|
│ │ └── Targets: card:<id>, slide:next, slide:prev, slide:first, slide:<index>
|
||||||
|
│ ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter
|
||||||
|
│ │ ├── Image slides use object-fit:contain; hotspots positioned relative to img box
|
||||||
|
│ │ ├── Arrow keys always work as fallback, even when hotspots are present
|
||||||
|
│ │ └── 'h' key toggles debug-hotspots class to visualize rects during presentation
|
||||||
|
│ ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick
|
||||||
|
│ ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id]
|
||||||
|
│ │ └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch
|
||||||
|
│ └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor
|
||||||
|
│
|
||||||
|
├── Gotchas / lessons learned
|
||||||
|
│ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
|
||||||
|
│ ├── inline-block vs block — pre with display:block stretches to container width,
|
||||||
|
│ │ making mouse-cell math wrong. Use width:max-content.
|
||||||
|
│ ├── auto-fit canvas — measure ONE char (set textContent='M'), then floor(container/charW)
|
||||||
|
│ ├── DOMPurify strips HTML comments — list:tree/bullets uses comments as markers, then
|
||||||
|
│ │ converts to classes BEFORE sanitize
|
||||||
|
│ ├── ALLOWED_TAGS in markdown.js — must include any tag plugins emit (pre, span, div ok)
|
||||||
|
│ ├── ALLOWED_ATTR — class, style, data-* are allowed; check before adding novel attrs
|
||||||
|
│ ├── theme.js handles tab switching for ALL .settings-tab, including dynamically added ones,
|
||||||
|
│ │ because forEach runs at module load AFTER plugins have injected their tabs
|
||||||
|
│ ├── CSRF token sync — app.js stores it via setCsrf() in util.js (module-private),
|
||||||
|
│ │ also calls window.hyprcrd.util.setCsrf() so plugins can read it
|
||||||
|
│ ├── Plugin tests can break core tab counts — don't hardcode toBe(N), use toContain checks
|
||||||
|
│ └── Header button injection — querySelector('#main-header .flex.items-center.gap-3:last-of-type')
|
||||||
|
│ to grab the right-side button group (calendar, gallery, etc.)
|
||||||
|
│
|
||||||
|
└── Adding a new plugin (checklist)
|
||||||
|
1. mkdir public/plugins/<name>
|
||||||
|
2. Write plugin.json (name, version, description, frontend.{js,css,html}, backend?)
|
||||||
|
3. If JS: import nothing from core. Use window.hyprcrd.* exclusively.
|
||||||
|
4. If HTML: provide overlay/modal markup. Use IDs prefixed with plugin name to avoid clashes.
|
||||||
|
5. If CSS: scope selectors to plugin-specific classes/IDs.
|
||||||
|
6. If backend: require_once your classes, define helpers in named functions (no closures
|
||||||
|
at top level — they pollute the global scope), register routes via onApiRoute().
|
||||||
|
7. If schema: CREATE TABLE IF NOT EXISTS in an IIFE at top of backend.php.
|
||||||
|
8. If credentials: reuse the vault key encryption pattern (see email/backend.php).
|
||||||
|
9. If settings: hyprcrd.settings.registerTab({...}) at the end of init().
|
||||||
|
10. Test by reloading; check window.hyprcrd.plugins includes your meta entry.
|
||||||
|
11. Verify it can be disabled via settings → plugins (assets must vanish from page source).
|
||||||
|
12. If adding a new core hook is unavoidable, document it HERE before merging.
|
||||||
|
```
|
||||||
|
|
||||||
|
## API quick reference (copy-paste safe)
|
||||||
|
|
||||||
|
### Register a markdown fenced block
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
|
||||||
|
return `<div class="my-block">${esc(content)}</div>`;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register an editor toolbar command
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.editor.registerCommand('mycmd', () => {
|
||||||
|
const ed = window.hyprcrd.editor.api;
|
||||||
|
if (!ed) return; // editor not initialized yet (shouldn't happen on user click)
|
||||||
|
ed.insertMd('<<', '>>', 'placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a button with data-md="mycmd" to #md-toolbar via JS:
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.dataset.md = 'mycmd';
|
||||||
|
btn.className = 'toolbar-btn';
|
||||||
|
btn.innerHTML = '<i class="ph ph-star text-sm"></i>';
|
||||||
|
document.getElementById('md-toolbar').appendChild(btn);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register a settings tab
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.settings.registerTab({
|
||||||
|
id: 'mything',
|
||||||
|
label: 'my thing',
|
||||||
|
icon: 'ph-star',
|
||||||
|
onShow: (panelEl) => {
|
||||||
|
panelEl.innerHTML = `<p>Settings UI here</p>`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open it programmatically:
|
||||||
|
window.hyprcrd.settings.openTab('mything');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register a backend API route (in backend.php)
|
||||||
|
|
||||||
|
```php
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
if ($route === '/api/myplugin/things' && $method === 'GET') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Make a CSRF-protected API call (frontend)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await window.hyprcrd.api('api/myplugin/things', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'foo' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encrypt credentials (in backend.php, reusing the vault key)
|
||||||
|
|
||||||
|
```php
|
||||||
|
function myEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
function myEncrypt(string $plain): string {
|
||||||
|
$key = myEncKey();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
function myDecrypt(string $encoded): string {
|
||||||
|
$key = myEncKey();
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookmarks Plugin: backend.
|
||||||
|
*
|
||||||
|
* Single endpoint that fetches a URL's <title> and favicon so the
|
||||||
|
* frontend doesn't have to deal with CORS. Light and stateless —
|
||||||
|
* the bookmarks themselves live in the card body (JSON blob).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/bookmarks/fetch-meta?url=... → { title, favicon }
|
||||||
|
*/
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return;
|
||||||
|
|
||||||
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
|
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'valid url required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
|
||||||
|
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'only http/https URLs allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!hyprcrdUrlHostIsPublic($url)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'URL host is not publicly reachable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$html = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$title = '';
|
||||||
|
if ($html && $status >= 200 && $status < 400) {
|
||||||
|
// Extract <title>
|
||||||
|
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
|
||||||
|
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
}
|
||||||
|
// og:title fallback
|
||||||
|
if ($title === '' && preg_match('/<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) {
|
||||||
|
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favicon: resolve the URL, then download the bytes and return a
|
||||||
|
// self-contained data: URI so the card body is fully offline-capable
|
||||||
|
// and opening the overlay doesn't leak requests to every bookmarked
|
||||||
|
// site. Caps at 32 KB — anything bigger is probably not an icon.
|
||||||
|
$parsed = parse_url($finalUrl);
|
||||||
|
$origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||||
|
$faviconUrl = $origin . '/favicon.ico';
|
||||||
|
if ($html && preg_match('/<link[^>]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) {
|
||||||
|
$href = $m[1];
|
||||||
|
if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href;
|
||||||
|
elseif (str_starts_with($href, '/')) $href = $origin . $href;
|
||||||
|
elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href;
|
||||||
|
$faviconUrl = $href;
|
||||||
|
}
|
||||||
|
|
||||||
|
$faviconData = '';
|
||||||
|
$faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME));
|
||||||
|
$favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl);
|
||||||
|
$ch2 = $favCh ? curl_init($faviconUrl) : null;
|
||||||
|
if ($ch2) curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 3,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
|
]);
|
||||||
|
if ($ch2) hyprcrdApplySafeCurlOpts($ch2);
|
||||||
|
$favBytes = $ch2 ? curl_exec($ch2) : false;
|
||||||
|
$favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0;
|
||||||
|
$favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon';
|
||||||
|
if ($ch2) curl_close($ch2);
|
||||||
|
|
||||||
|
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
||||||
|
$mime = preg_replace('/;.*$/', '', $favMime);
|
||||||
|
$faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'url' => $finalUrl,
|
||||||
|
'title' => mb_substr($title, 0, 200),
|
||||||
|
'favicon' => $faviconData,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/* ── Bookmarks plugin ── */
|
||||||
|
|
||||||
|
/* Board card override: the MutationObserver replaces the card's
|
||||||
|
innerHTML with a coloured tile (card.color as bg) showing a
|
||||||
|
centered link icon + the title. No body preview, no tags, no
|
||||||
|
metadata — just a clean, recognisable tile. */
|
||||||
|
.board-card.bm-card {
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bm-card-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
.bm-card-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 14px 8px;
|
||||||
|
}
|
||||||
|
.bm-card-icon {
|
||||||
|
font-size: 42px;
|
||||||
|
color: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.bm-card-title {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(0,0,0,0.55);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.bm-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.bm-card-meta i { font-size: 10px; margin-right: 2px; }
|
||||||
|
|
||||||
|
/* Overlay shell */
|
||||||
|
.bookmarks-shell {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bm-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Column width slider — full width */
|
||||||
|
.bookmarks-slider-row {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bookmarks-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--t-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bookmarks-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
.bookmarks-slider::-moz-range-thumb {
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column container — horizontal scroll, wheel redirected by JS */
|
||||||
|
.bookmarks-columns {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.bookmarks-columns::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Single column */
|
||||||
|
.bm-col {
|
||||||
|
flex: 0 0 var(--bm-col-w, 220px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bm-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-name {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-name:hover { color: var(--t-accent); }
|
||||||
|
.bm-col-header .bm-col-add {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-add:hover { color: var(--t-accent); }
|
||||||
|
|
||||||
|
.bm-col-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark item */
|
||||||
|
.bm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--t-raised, var(--t-surface));
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card, 3px);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bm-item:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 6%, var(--t-surface));
|
||||||
|
}
|
||||||
|
.bm-item .bm-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.bm-item .bm-favicon-placeholder {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bm-item .bm-item-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bm-item:hover .bm-item-title { color: var(--t-bright); }
|
||||||
|
.bm-item.dragging { opacity: 0.4; }
|
||||||
|
.bm-item.bm-drop-above {
|
||||||
|
box-shadow: 0 -2px 0 0 var(--t-accent);
|
||||||
|
}
|
||||||
|
.bm-item.bm-drop-below {
|
||||||
|
box-shadow: 0 2px 0 0 var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
.bm-ctx {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 300;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
border-radius: var(--ui-radius-modal, 4px);
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.bm-ctx button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.08s, color 0.08s;
|
||||||
|
}
|
||||||
|
.bm-ctx button:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||||
|
.bm-ctx button.danger:hover { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Empty column state */
|
||||||
|
.bm-col-empty {
|
||||||
|
padding: 16px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- Bookmarks overlay. Opens when the user clicks a #bookmarks card
|
||||||
|
on the board (instead of the normal detail view). Column-based
|
||||||
|
layout; each column is a topic, each item is a bookmark with
|
||||||
|
favicon + title. -->
|
||||||
|
<div id="bookmarks-overlay" class="overlay fixed inset-0 z-[80]" style="background:var(--t-overlay)">
|
||||||
|
<div class="bookmarks-shell">
|
||||||
|
<div class="bookmarks-header">
|
||||||
|
<i class="ph ph-link text-t-accent text-base"></i>
|
||||||
|
<span id="bookmarks-title" class="font-pixel text-sm text-t-accent flex-1 truncate cursor-pointer hover:text-t-bright transition-colors" data-tip="click to rename"></span>
|
||||||
|
<button id="bookmarks-add-btn" class="bm-btn" data-tip="new bookmark (paste URL)"><i class="ph ph-plus text-sm"></i></button>
|
||||||
|
<button id="bookmarks-col-add" class="bm-btn" data-tip="new column"><i class="ph ph-columns-plus-right text-sm"></i></button>
|
||||||
|
<button id="bookmarks-close" class="bm-btn hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="bookmarks-slider-row">
|
||||||
|
<input type="range" id="bookmarks-col-width" min="140" max="400" step="10" value="220" class="bookmarks-slider">
|
||||||
|
</div>
|
||||||
|
<div id="bookmarks-columns" class="bookmarks-columns"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
// ── Bookmarks Plugin ──
|
||||||
|
//
|
||||||
|
// A bookmark list is a card tagged #bookmarks. Its body is a JSON
|
||||||
|
// blob: { type:"bookmarks", columns:[{name, items:[{url,title,favicon}]}], columnWidth }.
|
||||||
|
// On the board the card renders as a coloured tile with a link icon
|
||||||
|
// and no body preview. Clicking opens a column-based overlay; each
|
||||||
|
// item is a small box with favicon + title. Click → open URL.
|
||||||
|
//
|
||||||
|
// Phase 1+2: skeleton, board decoration, overlay (read + write),
|
||||||
|
// add/delete/rename, clipboard paste + server-side meta fetch.
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) throw new Error('[bookmarks] window.hyprcrd missing');
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = 'bookmarks';
|
||||||
|
let activeCardId = null; // card id currently open in the overlay
|
||||||
|
let data = null; // parsed JSON body of the active card
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isBookmarkCard(card) {
|
||||||
|
return (card.tags || '').split(',').map(s => s.trim()).includes(TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyData() {
|
||||||
|
return { type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(body) {
|
||||||
|
const text = (body || '').trim();
|
||||||
|
// Full string might be pure JSON (legacy) or JSON + trailing
|
||||||
|
// "\n\n#bookmarks" tag line that extractTags needs on the server.
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
|
||||||
|
} catch {}
|
||||||
|
// Strip trailing tag line and retry.
|
||||||
|
const jsonEnd = text.lastIndexOf('}');
|
||||||
|
if (jsonEnd >= 0) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(text.substring(0, jsonEnd + 1));
|
||||||
|
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return emptyData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrl(s) {
|
||||||
|
return /^https?:\/\/.+/i.test((s || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCardLink(s) {
|
||||||
|
return /^card:\d+$/i.test((s || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCardId(s) {
|
||||||
|
const m = /^card:(\d+)$/i.exec((s || '').trim());
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!activeCardId || !data) return;
|
||||||
|
// The trailing #bookmarks tag is essential: the server's
|
||||||
|
// extractTags() parses hashtags from the body text to populate
|
||||||
|
// the `tags` column. Without it, isBookmarkCard (which checks
|
||||||
|
// tags, not body) wouldn't recognise the card on meta-only
|
||||||
|
// loads where body is still null.
|
||||||
|
const body = JSON.stringify(data) + '\n\n#bookmarks';
|
||||||
|
await hyprcrd.api(`api/cards/${activeCardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { body },
|
||||||
|
});
|
||||||
|
// Keep the local state in sync so the next openOverlay (which
|
||||||
|
// reads from state.cards, not the server) sees the latest body.
|
||||||
|
const local = (window.state?.cards || []).find(c => c.id == activeCardId);
|
||||||
|
if (local) local.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board card decoration ────────────────────────────────────────
|
||||||
|
// MutationObserver on #board: any card whose tags include "bookmarks"
|
||||||
|
// gets the .bm-card class + a link icon. Click is intercepted to
|
||||||
|
// open the overlay instead of the detail view.
|
||||||
|
|
||||||
|
function decorateBoard() {
|
||||||
|
const board = $('board');
|
||||||
|
if (!board) return;
|
||||||
|
const cards = window.state?.cards || [];
|
||||||
|
board.querySelectorAll('.board-card[data-id]').forEach(el => {
|
||||||
|
const card = cards.find(c => String(c.id) === el.dataset.id);
|
||||||
|
if (!card || !isBookmarkCard(card)) return;
|
||||||
|
if (el.dataset.bmDone) return;
|
||||||
|
el.dataset.bmDone = '1';
|
||||||
|
el.classList.add('bm-card');
|
||||||
|
// Replace the entire card content with a coloured tile
|
||||||
|
// showing just a big link icon and the title. The original
|
||||||
|
// innerHTML (bar + body preview + tags + meta) is noise for
|
||||||
|
// a bookmark list card.
|
||||||
|
const color = card.color || '#38bdf8';
|
||||||
|
el.style.background = `color-mix(in srgb, ${color} 70%, var(--t-surface))`;
|
||||||
|
el.style.borderColor = color;
|
||||||
|
const date = (card.updated_at || card.created_at || '').substring(0, 10);
|
||||||
|
const bucket = card.bucket_name || '';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="bm-card-inner">
|
||||||
|
<div class="bm-card-center">
|
||||||
|
<i class="ph ph-link bm-card-icon"></i>
|
||||||
|
<span class="bm-card-title">${esc(card.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bm-card-meta">
|
||||||
|
<span><i class="ph ph-clock"></i>${esc(date)}</span>
|
||||||
|
${bucket ? `<span><i class="ph ph-folder"></i>${esc(bucket)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
openOverlay(card.id);
|
||||||
|
}, true);
|
||||||
|
el.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
openOverlay(card.id);
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-decorate after every board render.
|
||||||
|
new MutationObserver(decorateBoard).observe(
|
||||||
|
$('board') || document.body,
|
||||||
|
{ childList: true, subtree: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Overlay ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const overlay = $('bookmarks-overlay');
|
||||||
|
const colsEl = $('bookmarks-columns');
|
||||||
|
const slider = $('bookmarks-col-width');
|
||||||
|
|
||||||
|
async function openOverlay(cardId) {
|
||||||
|
activeCardId = cardId;
|
||||||
|
let card = (window.state?.cards || []).find(c => c.id == cardId);
|
||||||
|
if (!card) return;
|
||||||
|
// Body might still be null if Phase-2 hasn't loaded it yet.
|
||||||
|
// Fetch it explicitly so we don't clobber the stored JSON
|
||||||
|
// (including columnWidth) with emptyData() defaults.
|
||||||
|
if (card.body == null) {
|
||||||
|
const full = await hyprcrd.api('api/cards?ids=' + cardId);
|
||||||
|
if (Array.isArray(full) && full[0]) {
|
||||||
|
const idx = (window.state?.cards || []).findIndex(c => c.id == cardId);
|
||||||
|
if (idx >= 0) window.state.cards[idx] = full[0];
|
||||||
|
card = full[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('bookmarks-title').textContent = card.title || 'Bookmarks';
|
||||||
|
data = parseBody(card.body);
|
||||||
|
if (slider) slider.value = data.columnWidth || 220;
|
||||||
|
applyColumnWidth();
|
||||||
|
renderColumns();
|
||||||
|
overlay?.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverlay() {
|
||||||
|
overlay?.classList.remove('open');
|
||||||
|
activeCardId = null;
|
||||||
|
data = null;
|
||||||
|
hideCtx();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnWidth() {
|
||||||
|
const w = slider ? parseInt(slider.value) : 220;
|
||||||
|
colsEl?.style.setProperty('--bm-col-w', w + 'px');
|
||||||
|
if (data) data.columnWidth = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumns() {
|
||||||
|
if (!colsEl || !data) return;
|
||||||
|
colsEl.innerHTML = '';
|
||||||
|
data.columns.forEach((col, ci) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'bm-col';
|
||||||
|
div.dataset.col = ci;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'bm-col-header';
|
||||||
|
hdr.innerHTML = `
|
||||||
|
<span class="bm-col-name" title="${esc(col.name)}">${esc(col.name)}</span>
|
||||||
|
<button class="bm-col-add" data-col="${ci}" title="add bookmark"><i class="ph ph-plus"></i></button>
|
||||||
|
`;
|
||||||
|
hdr.querySelector('.bm-col-name').addEventListener('click', () => renameColumn(ci));
|
||||||
|
hdr.querySelector('.bm-col-name').addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showColCtx(ci, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
hdr.querySelector('.bm-col-add').addEventListener('click', () => addBookmark(ci));
|
||||||
|
div.appendChild(hdr);
|
||||||
|
|
||||||
|
// Items
|
||||||
|
const items = document.createElement('div');
|
||||||
|
items.className = 'bm-col-items';
|
||||||
|
items.dataset.col = ci;
|
||||||
|
if (!col.items.length) {
|
||||||
|
items.innerHTML = '<div class="bm-col-empty">no bookmarks</div>';
|
||||||
|
} else {
|
||||||
|
col.items.forEach((it, ii) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'bm-item';
|
||||||
|
el.dataset.col = ci;
|
||||||
|
el.dataset.idx = ii;
|
||||||
|
el.draggable = true;
|
||||||
|
const cardId = parseCardId(it.url);
|
||||||
|
const isCard = !!cardId;
|
||||||
|
let fav;
|
||||||
|
if (isCard) {
|
||||||
|
// Card link: show a card icon instead of a web favicon.
|
||||||
|
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
|
||||||
|
const linkedColor = linked?.color || 'var(--t-accent)';
|
||||||
|
fav = `<i class="ph ph-card bm-favicon-placeholder" style="color:${esc(linkedColor)}"></i>`;
|
||||||
|
} else {
|
||||||
|
fav = it.favicon
|
||||||
|
? `<img class="bm-favicon" src="${esc(it.favicon)}" onerror="this.outerHTML='<i class=\\'ph ph-globe bm-favicon-placeholder\\'></i>'">`
|
||||||
|
: '<i class="ph ph-globe bm-favicon-placeholder"></i>';
|
||||||
|
}
|
||||||
|
el.innerHTML = `${fav}<span class="bm-item-title" title="${esc(it.title || it.url)}">${esc(it.title || it.url)}</span>`;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (isCard) {
|
||||||
|
// Check if the linked card is itself a bookmark list.
|
||||||
|
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
|
||||||
|
if (linked && isBookmarkCard(linked)) {
|
||||||
|
openOverlay(linked.id);
|
||||||
|
} else {
|
||||||
|
closeOverlay();
|
||||||
|
window._openDetail?.(parseInt(cardId));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (it.url) window.open(it.url, '_blank', 'noopener');
|
||||||
|
});
|
||||||
|
el.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showItemCtx(ci, ii, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
// Drag
|
||||||
|
el.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('application/x-bm', JSON.stringify({ col: ci, idx: ii }));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
el.classList.add('dragging');
|
||||||
|
});
|
||||||
|
el.addEventListener('dragend', () => el.classList.remove('dragging'));
|
||||||
|
items.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Drop target — positional: a drop indicator line appears
|
||||||
|
// between items based on the cursor's vertical position.
|
||||||
|
// The item is inserted AT that position, not always at the end.
|
||||||
|
items.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('application/x-bm')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
// Show a drop indicator between items.
|
||||||
|
clearDropIndicator(items);
|
||||||
|
const after = getDropIndex(items, e.clientY);
|
||||||
|
const children = [...items.querySelectorAll('.bm-item')];
|
||||||
|
if (after < children.length) {
|
||||||
|
children[after].classList.add('bm-drop-above');
|
||||||
|
} else if (children.length) {
|
||||||
|
children[children.length - 1].classList.add('bm-drop-below');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.addEventListener('dragleave', () => clearDropIndicator(items));
|
||||||
|
items.addEventListener('drop', (e) => {
|
||||||
|
clearDropIndicator(items);
|
||||||
|
const raw = e.dataTransfer.getData('application/x-bm');
|
||||||
|
if (!raw) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const src = JSON.parse(raw);
|
||||||
|
const destCol = ci;
|
||||||
|
if (!data) return;
|
||||||
|
const [item] = data.columns[src.col].items.splice(src.idx, 1);
|
||||||
|
if (!item) return;
|
||||||
|
// Compute insertion index from cursor position.
|
||||||
|
const insertAt = getDropIndex(items, e.clientY);
|
||||||
|
// If source and dest are the same column, the splice above
|
||||||
|
// shifted indices — getDropIndex already sees the shortened
|
||||||
|
// list, so insertAt is correct as-is.
|
||||||
|
data.columns[destCol].items.splice(insertAt, 0, item);
|
||||||
|
save();
|
||||||
|
renderColumns();
|
||||||
|
});
|
||||||
|
div.appendChild(items);
|
||||||
|
colsEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag-and-drop helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function getDropIndex(container, clientY) {
|
||||||
|
const children = [...container.querySelectorAll('.bm-item')];
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const r = children[i].getBoundingClientRect();
|
||||||
|
if (clientY < r.top + r.height / 2) return i;
|
||||||
|
}
|
||||||
|
return children.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDropIndicator(container) {
|
||||||
|
container.querySelectorAll('.bm-drop-above,.bm-drop-below').forEach(el => {
|
||||||
|
el.classList.remove('bm-drop-above', 'bm-drop-below');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wheel → horizontal scroll ────────────────────────────────────
|
||||||
|
colsEl?.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (colsEl.scrollWidth <= colsEl.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
colsEl.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// ── Slider ───────────────────────────────────────────────────────
|
||||||
|
slider?.addEventListener('input', () => {
|
||||||
|
applyColumnWidth();
|
||||||
|
renderColumns();
|
||||||
|
});
|
||||||
|
slider?.addEventListener('change', () => save());
|
||||||
|
|
||||||
|
// ── Context menus ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ctxEl = null;
|
||||||
|
|
||||||
|
function hideCtx() {
|
||||||
|
ctxEl?.remove();
|
||||||
|
ctxEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCtx(x, y, buttons) {
|
||||||
|
hideCtx();
|
||||||
|
ctxEl = document.createElement('div');
|
||||||
|
ctxEl.className = 'bm-ctx';
|
||||||
|
ctxEl.style.left = x + 'px';
|
||||||
|
ctxEl.style.top = y + 'px';
|
||||||
|
buttons.forEach(([label, icon, cls, fn]) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
if (cls) b.className = cls;
|
||||||
|
b.innerHTML = `<i class="ph ${icon} text-sm"></i>${esc(label)}`;
|
||||||
|
b.addEventListener('click', () => { hideCtx(); fn(); });
|
||||||
|
ctxEl.appendChild(b);
|
||||||
|
});
|
||||||
|
document.body.appendChild(ctxEl);
|
||||||
|
// Clamp to viewport
|
||||||
|
const r = ctxEl.getBoundingClientRect();
|
||||||
|
if (r.right > window.innerWidth - 8) ctxEl.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
|
||||||
|
if (r.bottom > window.innerHeight - 8) ctxEl.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
|
||||||
|
setTimeout(() => {
|
||||||
|
const off = (e) => { if (!ctxEl?.contains(e.target)) { hideCtx(); document.removeEventListener('mousedown', off, true); } };
|
||||||
|
document.addEventListener('mousedown', off, true);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItemCtx(col, idx, x, y) {
|
||||||
|
const item = data?.columns?.[col]?.items?.[idx];
|
||||||
|
if (!item) return;
|
||||||
|
showCtx(x, y, [
|
||||||
|
['Rename', 'ph-pencil-simple', '', async () => {
|
||||||
|
const name = await hyprcrd.dialog.show({ title: 'rename bookmark', input: true, defaultValue: item.title || '', okLabel: 'Save' });
|
||||||
|
if (name === false || name === null) return;
|
||||||
|
item.title = name;
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
['Delete', 'ph-trash', 'danger', async () => {
|
||||||
|
data.columns[col].items.splice(idx, 1);
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameColumn(col) {
|
||||||
|
const column = data?.columns?.[col];
|
||||||
|
if (!column) return;
|
||||||
|
const name = await hyprcrd.dialog.show({ title: 'rename column', input: true, defaultValue: column.name, okLabel: 'Save' });
|
||||||
|
if (name === false || name === null || name.trim() === '') return;
|
||||||
|
column.name = name.trim();
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showColCtx(col, x, y) {
|
||||||
|
const column = data?.columns?.[col];
|
||||||
|
if (!column) return;
|
||||||
|
showCtx(x, y, [
|
||||||
|
['Rename column', 'ph-pencil-simple', '', () => renameColumn(col)],
|
||||||
|
['Delete column', 'ph-trash', 'danger', async () => {
|
||||||
|
if (column.items.length) {
|
||||||
|
const ok = await hyprcrd.dialog.show({ title: 'delete column', message: `"${column.name}" has ${column.items.length} bookmark(s). Delete anyway?`, danger: true, okLabel: 'Delete' });
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
data.columns.splice(col, 1);
|
||||||
|
if (!data.columns.length) data.columns.push({ name: 'General', items: [] });
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add bookmark (clipboard + meta-fetch) ────────────────────────
|
||||||
|
|
||||||
|
async function addBookmark(colIdx) {
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const clip = await navigator.clipboard.readText();
|
||||||
|
if (isUrl(clip) || isCardLink(clip)) url = clip.trim();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
const entered = await hyprcrd.dialog.show({
|
||||||
|
title: 'new bookmark',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'https://... or card:123',
|
||||||
|
okLabel: 'Add',
|
||||||
|
});
|
||||||
|
if (!entered || !entered.trim()) return;
|
||||||
|
url = entered.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { url, title: url, favicon: '' };
|
||||||
|
|
||||||
|
if (isCardLink(url)) {
|
||||||
|
// Resolve card title for the display label.
|
||||||
|
const cid = parseCardId(url);
|
||||||
|
const card = (window.state?.cards || []).find(c => String(c.id) === cid);
|
||||||
|
if (card) item.title = card.title || url;
|
||||||
|
} else if (isUrl(url)) {
|
||||||
|
try {
|
||||||
|
const meta = await hyprcrd.api('api/bookmarks/fetch-meta?url=' + encodeURIComponent(url));
|
||||||
|
if (meta?.ok) {
|
||||||
|
if (meta.title) item.title = meta.title;
|
||||||
|
if (meta.favicon) item.favicon = meta.favicon;
|
||||||
|
if (meta.url) item.url = meta.url;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.columns[colIdx].items.push(item);
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add column ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function addColumn() {
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'new column',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'column name',
|
||||||
|
okLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
data.columns.push({ name: name.trim(), items: [] });
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
// Scroll to the new column
|
||||||
|
setTimeout(() => { colsEl.scrollLeft = colsEl.scrollWidth; }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header button injection ──────────────────────────────────────
|
||||||
|
// Creates a new #bookmarks card (or opens the most recent one).
|
||||||
|
|
||||||
|
function injectHeaderButton() {
|
||||||
|
if ($('bookmarks-btn')) return;
|
||||||
|
const header = document.querySelector('header#main-header');
|
||||||
|
if (!header) return;
|
||||||
|
const rows = header.querySelectorAll('.flex.items-center.gap-3');
|
||||||
|
const group = rows[rows.length - 1];
|
||||||
|
if (!group) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'bookmarks-btn';
|
||||||
|
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||||
|
btn.innerHTML = '<i class="ph ph-link text-sm"></i><span class="font-pixel text-[10px]">Bookmarks</span>';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cards = (window.state?.cards || []).filter(isBookmarkCard)
|
||||||
|
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||||
|
if (cards.length === 0) {
|
||||||
|
createBookmarkCard();
|
||||||
|
} else {
|
||||||
|
// Always show picker so the user can create new lists
|
||||||
|
// even when only one exists.
|
||||||
|
showBookmarkPicker(btn, cards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.insertBefore(btn, group.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rename the currently open bookmark card ──────────────────
|
||||||
|
|
||||||
|
async function renameCard() {
|
||||||
|
if (!activeCardId) return;
|
||||||
|
const card = (window.state?.cards || []).find(c => c.id == activeCardId);
|
||||||
|
const current = card?.title || '';
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'rename bookmark list',
|
||||||
|
input: true,
|
||||||
|
defaultValue: current,
|
||||||
|
okLabel: 'Save',
|
||||||
|
});
|
||||||
|
if (name === false || name === null || !name.trim()) return;
|
||||||
|
await hyprcrd.api(`api/cards/${activeCardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title: name.trim() },
|
||||||
|
});
|
||||||
|
$('bookmarks-title').textContent = name.trim();
|
||||||
|
await window._reloadAll?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create bookmark card with name prompt ────────────────────
|
||||||
|
|
||||||
|
async function createBookmarkCard() {
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'new bookmark list',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'list name...',
|
||||||
|
okLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const active = window.state?.activeBucket;
|
||||||
|
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
||||||
|
const bucketId = active && !virtualBuckets.includes(active) ? active : null;
|
||||||
|
const created = await hyprcrd.api('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
title: name.trim(),
|
||||||
|
body: JSON.stringify(emptyData()) + '\n\n#bookmarks',
|
||||||
|
color: '#38bdf8',
|
||||||
|
bucket_id: bucketId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (created?.id) {
|
||||||
|
await window._reloadAll?.();
|
||||||
|
openOverlay(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Picker popup (multiple bookmark cards) ───────────────────
|
||||||
|
|
||||||
|
function showBookmarkPicker(anchor, cards) {
|
||||||
|
document.querySelector('.bm-picker')?.remove();
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.className = 'bm-picker';
|
||||||
|
pop.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.bottom + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:200px;max-height:300px;display:flex;flex-direction:column;border-radius:var(--ui-radius-modal,4px);`;
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.style.cssText = 'overflow-y:auto;flex:1;';
|
||||||
|
cards.forEach(c => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
|
||||||
|
row.innerHTML = `<span style="width:10px;height:10px;border-radius:2px;background:${c.color || '#38bdf8'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(c.title)}</span>`;
|
||||||
|
row.addEventListener('mouseenter', () => { row.style.color = 'var(--t-bright)'; });
|
||||||
|
row.addEventListener('mouseleave', () => { row.style.color = 'var(--t-muted)'; });
|
||||||
|
row.addEventListener('click', () => { pop.remove(); openOverlay(c.id); });
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
pop.appendChild(list);
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px;background:none;border:none;border-top:1px solid var(--t-border);color:var(--t-accent);font-size:10px;font-family:var(--t-font-pixel);cursor:pointer;text-align:left;';
|
||||||
|
addBtn.innerHTML = '<i class="ph ph-plus text-xs"></i> new list';
|
||||||
|
addBtn.addEventListener('click', () => { pop.remove(); createBookmarkCard(); });
|
||||||
|
pop.appendChild(addBtn);
|
||||||
|
|
||||||
|
document.body.appendChild(pop);
|
||||||
|
// Clamp
|
||||||
|
const r = pop.getBoundingClientRect();
|
||||||
|
if (r.right > window.innerWidth - 8) pop.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
|
||||||
|
if (r.bottom > window.innerHeight - 8) pop.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
|
||||||
|
|
||||||
|
const close = (e) => { if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener('mousedown', close, true); } };
|
||||||
|
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
injectHeaderButton();
|
||||||
|
$('bookmarks-close')?.addEventListener('click', closeOverlay);
|
||||||
|
$('bookmarks-col-add')?.addEventListener('click', addColumn);
|
||||||
|
$('bookmarks-add-btn')?.addEventListener('click', () => {
|
||||||
|
if (data?.columns?.length) addBookmark(0);
|
||||||
|
});
|
||||||
|
$('bookmarks-title')?.addEventListener('click', renameCard);
|
||||||
|
overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); });
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && overlay?.classList.contains('open')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
decorateBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
hyprcrd.registerPlugin?.({
|
||||||
|
name: 'bookmarks',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Column-based bookmark manager stored as cards',
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "bookmarks",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Bookmark manager stored as cards. Column-based view with favicon fetch, clipboard paste, and drag-and-drop.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["bookmarks.js"],
|
||||||
|
"css": ["bookmarks.css"],
|
||||||
|
"html": ["bookmarks.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* Canvas Plugin styles */
|
||||||
|
#ascii-canvas-overlay { background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
|
||||||
|
.ascii-grid {
|
||||||
|
white-space: pre;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
display: inline-block;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.ascii-cursor { background: var(--t-accent); color: var(--t-bg); }
|
||||||
|
.ascii-char-btn {
|
||||||
|
font-family: var(--t-font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
color: var(--t-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ascii-char-btn:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||||
|
.ascii-char-btn.active {
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.ascii-char-group { display: inline-flex; gap: 2px; margin-right: 6px; }
|
||||||
|
#ascii-canvas-toolbar .toolbar-btn.active { background: var(--t-accent); color: var(--t-bg); }
|
||||||
|
|
||||||
|
/* Preview rendering of ```ascii blocks */
|
||||||
|
.prose pre.ascii-art {
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: pre;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!-- Canvas Plugin: ASCII art overlay -->
|
||||||
|
<div class="overlay fixed inset-0" id="ascii-canvas-overlay" style="z-index:80;">
|
||||||
|
<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 -->
|
||||||
|
<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" data-tip="Brush (B)"><i class="ph ph-pencil-simple 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" data-tip="Line (L)"><i class="ph ph-line-segment 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" data-tip="Ellipse (O)"><i class="ph ph-circle 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" 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>
|
||||||
|
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||||
|
<span>Char:</span>
|
||||||
|
<input id="ascii-char-input" type="text" value="#" maxlength="1" class="w-6 text-center bg-t-bg border border-t-border rounded text-t-text text-sm font-mono px-0 py-0">
|
||||||
|
</label>
|
||||||
|
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||||
|
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||||
|
<span>W:</span>
|
||||||
|
<input id="ascii-canvas-width" type="number" value="40" min="5" max="200" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||||
|
<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">
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
||||||
|
</div>
|
||||||
|
<!-- Char palette -->
|
||||||
|
<div id="ascii-char-palette" class="flex items-center gap-0.5 px-3 py-1.5 border-b border-t-border shrink-0 flex-wrap overflow-x-auto"></div>
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-auto bg-t-bg p-2">
|
||||||
|
<pre id="ascii-canvas-grid" class="ascii-grid font-mono text-sm text-t-text select-none cursor-crosshair" style="line-height:1.35;letter-spacing:0;margin:0;"></pre>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||||
|
<button id="ascii-canvas-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||||
|
<button id="ascii-canvas-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 rounded hover:opacity-90 cursor-pointer">Insert</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
// ── Canvas Plugin: ASCII Art Editor ──
|
||||||
|
//
|
||||||
|
// Self-registers via window.hyprcrd:
|
||||||
|
// - Markdown fenced block ```ascii``` → <pre class="ascii-art">
|
||||||
|
// - Editor toolbar button + "asciiart" command
|
||||||
|
// - Re-opens canvas to edit if cursor is inside an existing block
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) {
|
||||||
|
throw new Error('[canvas plugin] window.hyprcrd not found — plugin API missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const overlay = $('ascii-canvas-overlay');
|
||||||
|
const grid = $('ascii-canvas-grid');
|
||||||
|
const toolbar = $('ascii-canvas-toolbar');
|
||||||
|
const charInput = $('ascii-char-input');
|
||||||
|
const widthInput = $('ascii-canvas-width');
|
||||||
|
const heightInput = $('ascii-canvas-height');
|
||||||
|
const btnOk = $('ascii-canvas-ok');
|
||||||
|
const btnCancel = $('ascii-canvas-cancel');
|
||||||
|
const btnClear = $('ascii-canvas-clear');
|
||||||
|
const btnResize = $('ascii-canvas-resize');
|
||||||
|
const coordsDisplay = $('ascii-canvas-coords');
|
||||||
|
|
||||||
|
let data = []; // 2D array [row][col]
|
||||||
|
let cols = 40;
|
||||||
|
let rows = 20;
|
||||||
|
let tool = 'brush'; // brush | eraser | line | rect | circle | fill | text
|
||||||
|
let char = '#';
|
||||||
|
let drawing = false;
|
||||||
|
let startCell = null; // {r, c} for shape tools
|
||||||
|
let previewCells = []; // cells to highlight during drag
|
||||||
|
let onInsert = null; // callback(text)
|
||||||
|
let cellW = 0, cellH = 0;
|
||||||
|
let gridRect = null;
|
||||||
|
|
||||||
|
// ── Grid init ──
|
||||||
|
|
||||||
|
function initGrid(w, h) {
|
||||||
|
cols = w; rows = h;
|
||||||
|
data = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
data.push(new Array(cols).fill(' '));
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const lines = data.map(row => row.join(''));
|
||||||
|
grid.textContent = lines.join('\n');
|
||||||
|
measureCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureCells() {
|
||||||
|
if (!grid.firstChild) return;
|
||||||
|
// Use getBoundingClientRect on the grid (inline-block = content-sized)
|
||||||
|
gridRect = grid.getBoundingClientRect();
|
||||||
|
cellW = gridRect.width / cols;
|
||||||
|
cellH = gridRect.height / rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellFromEvent(e) {
|
||||||
|
// Always re-measure to account for scroll/layout changes
|
||||||
|
gridRect = grid.getBoundingClientRect();
|
||||||
|
if (!gridRect || !cellW || !cellH) return null;
|
||||||
|
const x = e.clientX - gridRect.left;
|
||||||
|
const y = e.clientY - gridRect.top;
|
||||||
|
const c = Math.floor(x / cellW);
|
||||||
|
const r = Math.floor(y / cellH);
|
||||||
|
if (r < 0 || r >= rows || c < 0 || c >= cols) return null;
|
||||||
|
return { r, c };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing algorithms ──
|
||||||
|
|
||||||
|
function setCell(r, c, ch) {
|
||||||
|
if (r >= 0 && r < rows && c >= 0 && c < cols) {
|
||||||
|
data[r][c] = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bresenhamLine(r0, c0, r1, c1) {
|
||||||
|
const cells = [];
|
||||||
|
let dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0);
|
||||||
|
let sr = r0 < r1 ? 1 : -1, sc = c0 < c1 ? 1 : -1;
|
||||||
|
let err = dc - dr;
|
||||||
|
let r = r0, c = c0;
|
||||||
|
while (true) {
|
||||||
|
cells.push({ r, c });
|
||||||
|
if (r === r1 && c === c1) break;
|
||||||
|
const e2 = 2 * err;
|
||||||
|
if (e2 > -dr) { err -= dr; c += sc; }
|
||||||
|
if (e2 < dc) { err += dc; r += sr; }
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectCells(r0, c0, r1, c1) {
|
||||||
|
const cells = [];
|
||||||
|
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
|
||||||
|
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
|
||||||
|
for (let c = cMin; c <= cMax; c++) { cells.push({ r: rMin, c }); cells.push({ r: rMax, c }); }
|
||||||
|
for (let r = rMin + 1; r < rMax; r++) { cells.push({ r, c: cMin }); cells.push({ r, c: cMax }); }
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleCells(r0, c0, r1, c1) {
|
||||||
|
const cells = [];
|
||||||
|
const cr = (r0 + r1) / 2, cc = (c0 + c1) / 2;
|
||||||
|
const rx = Math.abs(c1 - c0) / 2, ry = Math.abs(r1 - r0) / 2;
|
||||||
|
if (rx < 1 && ry < 1) { cells.push({ r: Math.round(cr), c: Math.round(cc) }); return cells; }
|
||||||
|
// Parametric ellipse
|
||||||
|
const steps = Math.max(60, Math.round(Math.max(rx, ry) * 4));
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const a = (2 * Math.PI * i) / steps;
|
||||||
|
const r = Math.round(cr + ry * Math.sin(a));
|
||||||
|
const c = Math.round(cc + rx * Math.cos(a));
|
||||||
|
const key = r + ',' + c;
|
||||||
|
if (!seen.has(key)) { seen.add(key); cells.push({ r, c }); }
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floodFill(r, c, ch) {
|
||||||
|
const target = data[r]?.[c];
|
||||||
|
if (target === undefined || target === ch) return;
|
||||||
|
const stack = [{ r, c }];
|
||||||
|
while (stack.length) {
|
||||||
|
const { r: cr, c: cc } = stack.pop();
|
||||||
|
if (cr < 0 || cr >= rows || cc < 0 || cc >= cols) continue;
|
||||||
|
if (data[cr][cc] !== target) continue;
|
||||||
|
data[cr][cc] = ch;
|
||||||
|
stack.push({ r: cr - 1, c: cc }, { r: cr + 1, c: cc }, { r: cr, c: cc - 1 }, { r: cr, c: cc + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering with preview overlay ──
|
||||||
|
|
||||||
|
function renderWithPreview(cells, ch) {
|
||||||
|
const preview = new Map();
|
||||||
|
for (const { r, c } of cells) {
|
||||||
|
if (r >= 0 && r < rows && c >= 0 && c < cols) {
|
||||||
|
preview.set(r + ',' + c, ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
let line = '';
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const key = r + ',' + c;
|
||||||
|
line += preview.has(key) ? preview.get(key) : data[r][c];
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
grid.textContent = lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mouse handlers ──
|
||||||
|
|
||||||
|
function onMouseDown(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
measureCells();
|
||||||
|
const cell = cellFromEvent(e);
|
||||||
|
if (!cell) return;
|
||||||
|
drawing = true;
|
||||||
|
startCell = cell;
|
||||||
|
|
||||||
|
if (tool === 'brush') {
|
||||||
|
setCell(cell.r, cell.c, char);
|
||||||
|
render();
|
||||||
|
} else if (tool === 'eraser') {
|
||||||
|
setCell(cell.r, cell.c, ' ');
|
||||||
|
render();
|
||||||
|
} else if (tool === 'fill') {
|
||||||
|
floodFill(cell.r, cell.c, char);
|
||||||
|
render();
|
||||||
|
drawing = false;
|
||||||
|
} else if (tool === 'text') {
|
||||||
|
// Start typing at this position
|
||||||
|
startTextMode(cell.r, cell.c);
|
||||||
|
drawing = false;
|
||||||
|
}
|
||||||
|
updateCoords(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e) {
|
||||||
|
const cell = cellFromEvent(e);
|
||||||
|
if (cell) updateCoords(cell);
|
||||||
|
if (!drawing || !cell) return;
|
||||||
|
|
||||||
|
if (tool === 'brush') {
|
||||||
|
// Draw line from last position for smooth strokes
|
||||||
|
if (startCell) {
|
||||||
|
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||||
|
for (const { r, c } of cells) setCell(r, c, char);
|
||||||
|
startCell = cell;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
} else if (tool === 'eraser') {
|
||||||
|
if (startCell) {
|
||||||
|
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||||
|
for (const { r, c } of cells) setCell(r, c, ' ');
|
||||||
|
startCell = cell;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
} else if (tool === 'line') {
|
||||||
|
const cells = bresenhamLine(startCell.r, startCell.c, cell.r, cell.c);
|
||||||
|
renderWithPreview(cells, char);
|
||||||
|
} else if (tool === 'rect') {
|
||||||
|
const cells = rectCells(startCell.r, startCell.c, cell.r, cell.c);
|
||||||
|
renderWithPreview(cells, char);
|
||||||
|
} else if (tool === 'circle') {
|
||||||
|
const cells = circleCells(startCell.r, startCell.c, cell.r, cell.c);
|
||||||
|
renderWithPreview(cells, char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp(e) {
|
||||||
|
if (!drawing) return;
|
||||||
|
const cell = cellFromEvent(e);
|
||||||
|
if (!cell) { drawing = false; return; }
|
||||||
|
|
||||||
|
if (tool === 'line') {
|
||||||
|
for (const p of bresenhamLine(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||||
|
} else if (tool === 'rect') {
|
||||||
|
for (const p of rectCells(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||||
|
} else if (tool === 'circle') {
|
||||||
|
for (const p of circleCells(startCell.r, startCell.c, cell.r, cell.c)) setCell(p.r, p.c, char);
|
||||||
|
}
|
||||||
|
drawing = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text mode ──
|
||||||
|
|
||||||
|
let textCursor = null;
|
||||||
|
|
||||||
|
function startTextMode(r, c) {
|
||||||
|
textCursor = { r, c };
|
||||||
|
renderTextCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextCursor() {
|
||||||
|
if (!textCursor) { render(); return; }
|
||||||
|
// Show cursor as inverted cell
|
||||||
|
const lines = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
let line = '';
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
line += data[r][c];
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
// Build HTML with cursor highlight
|
||||||
|
let html = '';
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const ch = data[r][c] === ' ' ? '\u00a0' : data[r][c];
|
||||||
|
if (r === textCursor.r && c === textCursor.c) {
|
||||||
|
html += `<span class="ascii-cursor">${ch}</span>`;
|
||||||
|
} else {
|
||||||
|
html += escChar(data[r][c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r < rows - 1) html += '\n';
|
||||||
|
}
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escChar(ch) {
|
||||||
|
if (ch === '<') return '<';
|
||||||
|
if (ch === '>') return '>';
|
||||||
|
if (ch === '&') return '&';
|
||||||
|
if (ch === ' ') return ' ';
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextKey(e) {
|
||||||
|
if (!textCursor) return false;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
textCursor = null;
|
||||||
|
render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') { textCursor.c = Math.max(0, textCursor.c - 1); renderTextCursor(); return true; }
|
||||||
|
if (e.key === 'ArrowRight') { textCursor.c = Math.min(cols - 1, textCursor.c + 1); renderTextCursor(); return true; }
|
||||||
|
if (e.key === 'ArrowUp') { textCursor.r = Math.max(0, textCursor.r - 1); renderTextCursor(); return true; }
|
||||||
|
if (e.key === 'ArrowDown') { textCursor.r = Math.min(rows - 1, textCursor.r + 1); renderTextCursor(); return true; }
|
||||||
|
if (e.key === 'Backspace') {
|
||||||
|
textCursor.c = Math.max(0, textCursor.c - 1);
|
||||||
|
setCell(textCursor.r, textCursor.c, ' ');
|
||||||
|
renderTextCursor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
textCursor.r = Math.min(rows - 1, textCursor.r + 1);
|
||||||
|
textCursor.c = 0;
|
||||||
|
renderTextCursor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||||
|
setCell(textCursor.r, textCursor.c, e.key);
|
||||||
|
textCursor.c = Math.min(cols - 1, textCursor.c + 1);
|
||||||
|
renderTextCursor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Coords display ──
|
||||||
|
|
||||||
|
function updateCoords(cell) {
|
||||||
|
if (coordsDisplay) coordsDisplay.textContent = `${cell.c + 1}:${cell.r + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool selection ──
|
||||||
|
|
||||||
|
function selectTool(t) {
|
||||||
|
tool = t;
|
||||||
|
textCursor = null;
|
||||||
|
render();
|
||||||
|
toolbar.querySelectorAll('[data-tool]').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.tool === t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Character palette ──
|
||||||
|
|
||||||
|
const CHAR_SETS = {
|
||||||
|
basic: ['#', '*', '.', '+', '-', '|', '/', '\\', '=', '_', '~', ':', '^', '@', 'o', 'x'],
|
||||||
|
box: ['─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼'],
|
||||||
|
boxDouble: ['═', '║', '╔', '╗', '╚', '╝', '╠', '╣', '╦', '╩', '╬'],
|
||||||
|
blocks: ['█', '▓', '▒', '░', '▄', '▀', '▌', '▐'],
|
||||||
|
arrows: ['←', '→', '↑', '↓', '↔', '↕', '►', '◄', '▲', '▼'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPalette() {
|
||||||
|
const palette = $('ascii-char-palette');
|
||||||
|
if (!palette) return;
|
||||||
|
palette.innerHTML = '';
|
||||||
|
for (const [group, chars] of Object.entries(CHAR_SETS)) {
|
||||||
|
const grp = document.createElement('div');
|
||||||
|
grp.className = 'ascii-char-group';
|
||||||
|
for (const ch of chars) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ascii-char-btn';
|
||||||
|
btn.textContent = ch;
|
||||||
|
btn.title = ch;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
char = ch;
|
||||||
|
charInput.value = ch;
|
||||||
|
palette.querySelectorAll('.ascii-char-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
});
|
||||||
|
if (ch === char) btn.classList.add('active');
|
||||||
|
grp.appendChild(btn);
|
||||||
|
}
|
||||||
|
palette.appendChild(grp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ──
|
||||||
|
|
||||||
|
function exportText() {
|
||||||
|
// Trim trailing spaces per line, remove trailing empty lines
|
||||||
|
const lines = data.map(row => row.join('').replace(/\s+$/, ''));
|
||||||
|
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open / Close ──
|
||||||
|
|
||||||
|
export function openCanvas(callback, existingText) {
|
||||||
|
onInsert = callback;
|
||||||
|
if (existingText) {
|
||||||
|
// Parse existing canvas content
|
||||||
|
const lines = existingText.split('\n');
|
||||||
|
rows = Math.max(lines.length, 5);
|
||||||
|
cols = Math.max(...lines.map(l => l.length), 20);
|
||||||
|
heightInput.value = rows;
|
||||||
|
widthInput.value = cols;
|
||||||
|
data = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const row = [];
|
||||||
|
const line = lines[r] || '';
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
row.push(c < line.length ? line[c] : ' ');
|
||||||
|
}
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-fit to available grid container space
|
||||||
|
overlay.classList.add('open');
|
||||||
|
const container = grid.parentElement;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const style = getComputedStyle(grid);
|
||||||
|
// Measure one character by rendering a single char
|
||||||
|
grid.textContent = 'M';
|
||||||
|
const charW = grid.getBoundingClientRect().width;
|
||||||
|
const charH = grid.getBoundingClientRect().height;
|
||||||
|
grid.textContent = '';
|
||||||
|
if (charW > 0 && charH > 0) {
|
||||||
|
const pad = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||||
|
cols = Math.max(20, Math.floor((container.clientWidth - pad) / charW));
|
||||||
|
rows = Math.max(5, Math.floor((container.clientHeight - pad) / charH));
|
||||||
|
} else {
|
||||||
|
cols = 80;
|
||||||
|
rows = 30;
|
||||||
|
}
|
||||||
|
widthInput.value = cols;
|
||||||
|
heightInput.value = rows;
|
||||||
|
initGrid(cols, rows);
|
||||||
|
buildPalette();
|
||||||
|
selectTool('brush');
|
||||||
|
measureCells();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildPalette();
|
||||||
|
selectTool('brush');
|
||||||
|
overlay.classList.add('open');
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(() => measureCells());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCanvas() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
textCursor = null;
|
||||||
|
drawing = false;
|
||||||
|
onInsert = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event binding ──
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
grid.addEventListener('mousedown', onMouseDown);
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
// Keyboard for text tool + tool shortcuts
|
||||||
|
overlay.addEventListener('keydown', e => {
|
||||||
|
if (textCursor && handleTextKey(e)) { e.preventDefault(); e.stopPropagation(); return; }
|
||||||
|
if (e.key === 'Escape') { closeCanvas(); e.stopPropagation(); return; }
|
||||||
|
if (e.ctrlKey || e.metaKey) return;
|
||||||
|
const shortcuts = { b: 'brush', e: 'eraser', l: 'line', r: 'rect', o: 'circle', f: 'fill', t: 'text' };
|
||||||
|
if (shortcuts[e.key]) { selectTool(shortcuts[e.key]); e.preventDefault(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool buttons
|
||||||
|
toolbar.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-tool]');
|
||||||
|
if (btn) selectTool(btn.dataset.tool);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Char input
|
||||||
|
charInput.addEventListener('input', () => {
|
||||||
|
if (charInput.value.length > 0) {
|
||||||
|
char = charInput.value[charInput.value.length - 1];
|
||||||
|
charInput.value = char;
|
||||||
|
// Update palette highlight
|
||||||
|
const palette = $('ascii-char-palette');
|
||||||
|
palette?.querySelectorAll('.ascii-char-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.textContent === char);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OK button
|
||||||
|
btnOk.addEventListener('click', () => {
|
||||||
|
const text = exportText();
|
||||||
|
if (onInsert && text.trim()) onInsert(text);
|
||||||
|
closeCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
btnCancel.addEventListener('click', closeCanvas);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
btnClear.addEventListener('click', () => {
|
||||||
|
initGrid(cols, rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
btnResize.addEventListener('click', () => {
|
||||||
|
const newW = parseInt(widthInput.value) || cols;
|
||||||
|
const newH = parseInt(heightInput.value) || rows;
|
||||||
|
// Preserve existing content
|
||||||
|
const oldData = data;
|
||||||
|
const oldRows = rows, oldCols = cols;
|
||||||
|
cols = Math.max(5, Math.min(200, newW));
|
||||||
|
rows = Math.max(3, Math.min(100, newH));
|
||||||
|
widthInput.value = cols;
|
||||||
|
heightInput.value = rows;
|
||||||
|
data = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const row = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
row.push(r < oldRows && c < oldCols ? oldData[r][c] : ' ');
|
||||||
|
}
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on overlay background click
|
||||||
|
overlay.addEventListener('click', e => {
|
||||||
|
if (e.target === overlay) closeCanvas();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
// ── Plugin self-registration ──────────────────────────────────────
|
||||||
|
|
||||||
|
// Register markdown fenced block: ```ascii``` → styled <pre>
|
||||||
|
hyprcrd.markdown.registerFencedBlock('ascii', (content, { esc }) => {
|
||||||
|
return `<pre class="ascii-art">${esc(content.replace(/\s+$/, ''))}</pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register editor toolbar command "asciiart"
|
||||||
|
hyprcrd.editor.registerCommand('asciiart', () => {
|
||||||
|
const ed = hyprcrd.editor.api;
|
||||||
|
if (!ed || !ed.body) return;
|
||||||
|
const body = ed.body;
|
||||||
|
|
||||||
|
// Detect if cursor is inside an existing ```ascii``` block (edit mode)
|
||||||
|
const val = body.value;
|
||||||
|
const pos = body.selectionStart;
|
||||||
|
const before = val.substring(0, pos);
|
||||||
|
const blockStart = before.lastIndexOf('```ascii\n');
|
||||||
|
const blockEndInBefore = blockStart >= 0 ? before.indexOf('\n```', blockStart + 9) : -1;
|
||||||
|
let existingText = null, replaceStart = 0, replaceEnd = 0;
|
||||||
|
if (blockStart >= 0 && blockEndInBefore < 0) {
|
||||||
|
const contentStart = blockStart + 9;
|
||||||
|
const endMark = val.indexOf('\n```', contentStart);
|
||||||
|
if (endMark >= 0) {
|
||||||
|
existingText = val.substring(contentStart, endMark);
|
||||||
|
replaceStart = blockStart;
|
||||||
|
replaceEnd = endMark + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCanvas(text => {
|
||||||
|
const block = '```ascii\n' + text + '\n```';
|
||||||
|
if (existingText !== null) {
|
||||||
|
body.setRangeText(block, replaceStart, replaceEnd, 'end');
|
||||||
|
} else {
|
||||||
|
const s = body.selectionStart;
|
||||||
|
const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : '';
|
||||||
|
body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end');
|
||||||
|
}
|
||||||
|
ed.updatePreview?.();
|
||||||
|
}, existingText);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject toolbar button into #md-toolbar (after the "extract" button if present)
|
||||||
|
function injectToolbarButton() {
|
||||||
|
const toolbar = $('md-toolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
if (toolbar.querySelector('[data-md="asciiart"]')) return; // already injected
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.setAttribute('data-md', 'asciiart');
|
||||||
|
btn.setAttribute('title', 'ASCII Canvas');
|
||||||
|
btn.className = 'toolbar-btn';
|
||||||
|
btn.innerHTML = '<i class="ph ph-grid-four text-sm"></i>';
|
||||||
|
// Insert before the right-side spacer (flex-1 div), or append at end
|
||||||
|
const spacer = toolbar.querySelector('.flex-1');
|
||||||
|
if (spacer) toolbar.insertBefore(btn, spacer);
|
||||||
|
else toolbar.appendChild(btn);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectToolbarButton);
|
||||||
|
} else {
|
||||||
|
injectToolbarButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register plugin metadata for introspection
|
||||||
|
hyprcrd.registerPlugin({
|
||||||
|
name: 'canvas',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'ASCII art canvas editor',
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "ASCII art canvas editor — draw with characters in a monospace grid and embed via ```ascii blocks.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["canvas.js"],
|
||||||
|
"css": ["canvas.css"],
|
||||||
|
"html": ["canvas.html"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat Plugin: backend.
|
||||||
|
*
|
||||||
|
* Local message cache + thin proxy over the relays /api/chat routes.
|
||||||
|
* The plugin keeps a local SQLite table so the UI can render conversation
|
||||||
|
* history without re-polling on every open. Reuses the existing relay_url /
|
||||||
|
* relay_username / relay_key settings (no new credentials).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/chat/conversations → peer list with last msg + unread
|
||||||
|
* GET /api/chat/messages?peer=X → all messages with one peer (asc)
|
||||||
|
* POST /api/chat/send {peer,body} → encrypts + pushes via relay, stores local
|
||||||
|
* POST /api/chat/poll → pulls new from relay, returns count
|
||||||
|
* POST /api/chat/read {peer} → mark all incoming from peer as read
|
||||||
|
* GET /api/chat/recipients → proxy of relays /api/tenants
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
peer TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
relay_id INTEGER,
|
||||||
|
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)");
|
||||||
|
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL");
|
||||||
|
// Read receipts for outgoing messages: `delivered` = recipient polled
|
||||||
|
// (single check), `seen` = recipient opened the thread (double check).
|
||||||
|
// Both filled in via /api/chat/sent receipt-poll on the relay side.
|
||||||
|
$cols = $db->query("PRAGMA table_info(chat_messages)")->fetchAll();
|
||||||
|
$colNames = array_column($cols, 'name');
|
||||||
|
if (!in_array('delivered', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!in_array('seen', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
// Group chat: `room_uid` set → message belongs to a room (group
|
||||||
|
// thread), and `peer` holds the author's username (always — 1:1
|
||||||
|
// messages use peer for the other party, group messages reuse the
|
||||||
|
// column for the sender). NULL room_uid = classic 1:1.
|
||||||
|
if (!in_array('room_uid', $colNames)) {
|
||||||
|
$db->exec("ALTER TABLE chat_messages ADD COLUMN room_uid TEXT");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat rooms + membership, mirrored from the relay on poll. The
|
||||||
|
// client is the read-through cache; relay remains the source of
|
||||||
|
// truth so a second device can join a room just by polling.
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_room_members (
|
||||||
|
room_uid TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (room_uid, username)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Per-thread settings. Currently: `disposable` flag for 1:1 threads
|
||||||
|
// — when true, the relay drops the messages after the 7-day window
|
||||||
|
// instead of keeping them in the long-term store. Rooms are always
|
||||||
|
// persistent (enforced relay-side), so this only applies to peers.
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_thread_settings (
|
||||||
|
peer TEXT PRIMARY KEY,
|
||||||
|
disposable INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
");
|
||||||
|
})();
|
||||||
|
|
||||||
|
function chatJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatRelaySettings(): array {
|
||||||
|
$db = Database::connect();
|
||||||
|
return [
|
||||||
|
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
|
||||||
|
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
|
||||||
|
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dual-format envelope shared with the relay sync path. Writes V2
|
||||||
|
* (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central
|
||||||
|
* helpers so the format stays in lock-step with src/bootstrap.php and
|
||||||
|
* the relay's crypto.php. */
|
||||||
|
function chatEncrypt(string $plain, string $relayKey): string {
|
||||||
|
return hyprcrdRelayEncrypt($plain, $relayKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||||
|
// Old behaviour returned '' for non-envelope strings; preserve that.
|
||||||
|
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return hyprcrdRelayDecrypt($envelope, $relayKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatGetState(string $key, $default = null) {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT value FROM chat_state WHERE key = :k');
|
||||||
|
$stmt->execute(['k' => $key]);
|
||||||
|
$v = $stmt->fetchColumn();
|
||||||
|
return $v === false ? $default : $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatSetState(string $key, $value): void {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('INSERT OR REPLACE INTO chat_state (key, value) VALUES (:k, :v)')
|
||||||
|
->execute(['k' => $key, 'v' => (string)$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
|
||||||
|
if ($route === '/api/chat/conversations' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
// 1:1 threads — one row per peer (room_uid IS NULL).
|
||||||
|
$oneOnOne = $db->query("
|
||||||
|
SELECT
|
||||||
|
'peer' AS kind,
|
||||||
|
peer AS key,
|
||||||
|
peer AS label,
|
||||||
|
MAX(sent_at) AS last_at,
|
||||||
|
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer AND c2.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_body,
|
||||||
|
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer AND c3.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_direction,
|
||||||
|
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
|
||||||
|
FROM chat_messages c1
|
||||||
|
WHERE room_uid IS NULL
|
||||||
|
GROUP BY peer
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
// Group rooms — join rooms to their newest message (if any); rooms
|
||||||
|
// with no messages yet still render so the user can post there.
|
||||||
|
$rooms = $db->query("
|
||||||
|
SELECT
|
||||||
|
'room' AS kind,
|
||||||
|
r.uid AS key,
|
||||||
|
r.name AS label,
|
||||||
|
r.owner_username AS owner,
|
||||||
|
(SELECT MAX(sent_at) FROM chat_messages WHERE room_uid = r.uid) AS last_at,
|
||||||
|
(SELECT body FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_body,
|
||||||
|
(SELECT peer FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_author,
|
||||||
|
(SELECT COUNT(*) FROM chat_messages WHERE room_uid = r.uid AND direction = 'in' AND is_read = 0) AS unread
|
||||||
|
FROM chat_rooms r
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
$rows = array_merge($oneOnOne, $rooms);
|
||||||
|
// Sort newest-first, nulls (empty rooms) to the bottom.
|
||||||
|
usort($rows, function ($a, $b) {
|
||||||
|
$al = $a['last_at'] ?? ''; $bl = $b['last_at'] ?? '';
|
||||||
|
if ($al === $bl) return 0;
|
||||||
|
if ($al === '') return 1;
|
||||||
|
if ($bl === '') return -1;
|
||||||
|
return strcmp($bl, $al);
|
||||||
|
});
|
||||||
|
$totalUnread = array_sum(array_map(fn($r) => (int)($r['unread'] ?? 0), $rows));
|
||||||
|
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/messages' && $method === 'GET') {
|
||||||
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
|
$roomUid = trim((string)($_GET['room_uid'] ?? ''));
|
||||||
|
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||||
|
$db = Database::connect();
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
// Group thread: return all messages for the room. `peer` is the
|
||||||
|
// author username; UI uses author === self to decide bubble
|
||||||
|
// alignment instead of the direction flag (direction is set
|
||||||
|
// from THIS device's perspective, so a message sent from
|
||||||
|
// another device comes in as direction='in' while peer==self).
|
||||||
|
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen, room_uid FROM chat_messages WHERE room_uid = :r ORDER BY id ASC LIMIT 500');
|
||||||
|
$stmt->execute(['r' => $roomUid]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p AND room_uid IS NULL ORDER BY id ASC LIMIT 500');
|
||||||
|
$stmt->execute(['p' => $peer]);
|
||||||
|
}
|
||||||
|
chatJson(['messages' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/send' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
|
$body = (string)($input['body'] ?? '');
|
||||||
|
if ($body === '') chatJson(['error' => 'body required'], 400);
|
||||||
|
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||||
|
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||||
|
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
// Look up per-thread disposable flag (1:1 only; rooms are always
|
||||||
|
// persistent on the relay).
|
||||||
|
$disposable = 0;
|
||||||
|
if ($peer !== '') {
|
||||||
|
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
|
||||||
|
$stmt->execute(['p' => $peer]);
|
||||||
|
$disposable = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$envelope = chatEncrypt($body, $r['key']);
|
||||||
|
$payload = $roomUid !== ''
|
||||||
|
? ['room_uid' => $roomUid, 'body' => $envelope]
|
||||||
|
: ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable];
|
||||||
|
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
|
||||||
|
$ch = curl_init($relayChatUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
$relayErr = '';
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||||
|
chatJson([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => "relay returned HTTP $status$relayErr",
|
||||||
|
'url' => $relayChatUrl,
|
||||||
|
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resData = json_decode($response, true);
|
||||||
|
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||||
|
|
||||||
|
// Store the outgoing message locally so the UI can render it
|
||||||
|
// immediately. For group messages `peer` holds the author
|
||||||
|
// (= self); for 1:1 it holds the other party as before.
|
||||||
|
$localPeer = $roomUid !== '' ? $r['user'] : $peer;
|
||||||
|
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen, room_uid) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0, :ru)')
|
||||||
|
->execute([
|
||||||
|
'p' => $localPeer,
|
||||||
|
'b' => $body,
|
||||||
|
'rid' => $relayId,
|
||||||
|
'ru' => $roomUid !== '' ? $roomUid : null,
|
||||||
|
]);
|
||||||
|
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/poll' && $method === 'POST') {
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||||
|
chatJson(['ok' => true, 'new' => 0, 'note' => 'relay not configured']);
|
||||||
|
}
|
||||||
|
$since = (int)chatGetState('last_relay_id', 0);
|
||||||
|
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat?since=' . $since);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
$relayErr = '';
|
||||||
|
$decoded = json_decode($response, true);
|
||||||
|
if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error'];
|
||||||
|
chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true) ?: [];
|
||||||
|
$messages = $data['messages'] ?? [];
|
||||||
|
$maxId = (int)($data['max_id'] ?? $since);
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$newCount = 0;
|
||||||
|
// `mine` on group messages (== came back from the relay because
|
||||||
|
// another device of ours sent it) gets direction='out' so the UI
|
||||||
|
// renders it aligned right; relay_id uniqueness dedupes against
|
||||||
|
// the local insert we did on send.
|
||||||
|
$insert = $db->prepare('
|
||||||
|
INSERT OR IGNORE INTO chat_messages
|
||||||
|
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
|
||||||
|
VALUES (:p, :dir, :b, :rid, :ts, 0, :ru)
|
||||||
|
');
|
||||||
|
foreach ($messages as $m) {
|
||||||
|
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||||
|
if ($plain === '') continue;
|
||||||
|
$isGroup = !empty($m['room_uid']);
|
||||||
|
$mine = !empty($m['mine']);
|
||||||
|
$insert->execute([
|
||||||
|
'p' => $m['sender_username'] ?? '?',
|
||||||
|
'dir' => $mine ? 'out' : 'in',
|
||||||
|
'b' => $plain,
|
||||||
|
'rid' => (int)$m['id'],
|
||||||
|
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||||
|
'ru' => $isGroup ? $m['room_uid'] : null,
|
||||||
|
]);
|
||||||
|
if ($insert->rowCount() > 0) $newCount++;
|
||||||
|
}
|
||||||
|
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||||
|
|
||||||
|
// Mirror rooms + membership into the local cache on every poll.
|
||||||
|
// Cheap (one extra GET), and it lets a second device see a room
|
||||||
|
// it was just added to without a separate fetch.
|
||||||
|
$ch3 = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
|
||||||
|
curl_setopt_array($ch3, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$resp3 = curl_exec($ch3);
|
||||||
|
$st3 = curl_getinfo($ch3, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch3);
|
||||||
|
if ($st3 === 200) {
|
||||||
|
$roomsData = json_decode($resp3, true) ?: [];
|
||||||
|
$rooms = $roomsData['rooms'] ?? [];
|
||||||
|
$remoteUids = [];
|
||||||
|
$upsert = $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, :c, datetime("now"))');
|
||||||
|
$delMem = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u');
|
||||||
|
$insMem = $db->prepare('INSERT OR IGNORE INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
|
||||||
|
foreach ($rooms as $room) {
|
||||||
|
$uid = (string)($room['uid'] ?? '');
|
||||||
|
if ($uid === '') continue;
|
||||||
|
$remoteUids[] = $uid;
|
||||||
|
$upsert->execute([
|
||||||
|
'u' => $uid,
|
||||||
|
'n' => (string)($room['name'] ?? $uid),
|
||||||
|
'o' => (string)($room['owner_username'] ?? ''),
|
||||||
|
'c' => (string)($room['created_at'] ?? date('Y-m-d H:i:s')),
|
||||||
|
]);
|
||||||
|
$delMem->execute(['u' => $uid]);
|
||||||
|
foreach (($room['members'] ?? []) as $member) {
|
||||||
|
$insMem->execute(['u' => $uid, 'un' => (string)$member]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop local rooms we were removed from (owner kick / self-leave
|
||||||
|
// on another device). Messages for them stay on disk — harmless,
|
||||||
|
// UI won't list them without a room entry.
|
||||||
|
$existing = $db->query('SELECT uid FROM chat_rooms')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($existing as $uid) {
|
||||||
|
if (!in_array($uid, $remoteUids, true)) {
|
||||||
|
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receipt poll for our SENT messages ──────────────────
|
||||||
|
// Pulls fetched/seen state for everything we've sent. Updates
|
||||||
|
// local outgoing rows by relay_id. Uses an independent `since`
|
||||||
|
// cursor so we keep getting state changes even on rows we
|
||||||
|
// already saw before.
|
||||||
|
$sentSince = (int)chatGetState('last_sent_relay_id', 0);
|
||||||
|
$ch2 = curl_init(rtrim($r['url'], '/') . '/api/chat/sent?since=' . $sentSince);
|
||||||
|
curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$resp2 = curl_exec($ch2);
|
||||||
|
$st2 = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch2);
|
||||||
|
$receiptUpdates = 0;
|
||||||
|
if ($st2 === 200) {
|
||||||
|
$sentData = json_decode($resp2, true) ?: [];
|
||||||
|
$receipts = $sentData['receipts'] ?? [];
|
||||||
|
$sentMaxId = (int)($sentData['max_id'] ?? $sentSince);
|
||||||
|
$upd = $db->prepare('UPDATE chat_messages SET delivered = :d, seen = :s WHERE relay_id = :rid AND direction = "out"');
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
$upd->execute([
|
||||||
|
'd' => (int)($rcpt['fetched'] ?? 0),
|
||||||
|
's' => (int)($rcpt['seen'] ?? 0),
|
||||||
|
'rid' => (int)$rcpt['id'],
|
||||||
|
]);
|
||||||
|
$receiptUpdates += $upd->rowCount();
|
||||||
|
}
|
||||||
|
// Cursor only advances when EVERY row's state is final (seen=1).
|
||||||
|
// Otherwise we need to keep re-polling those rows for updates.
|
||||||
|
// Cheap implementation: if every row in this batch is seen, bump
|
||||||
|
// cursor to maxId; else keep polling from the lowest non-seen id.
|
||||||
|
$allSeen = true;
|
||||||
|
$minPending = $sentMaxId;
|
||||||
|
foreach ($receipts as $rcpt) {
|
||||||
|
if ((int)($rcpt['seen'] ?? 0) !== 1) {
|
||||||
|
$allSeen = false;
|
||||||
|
if ((int)$rcpt['id'] - 1 < $minPending) $minPending = (int)$rcpt['id'] - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($allSeen && $sentMaxId > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $sentMaxId);
|
||||||
|
} else if (!$allSeen && $minPending > $sentSince) {
|
||||||
|
chatSetState('last_sent_relay_id', $minPending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatJson(['ok' => true, 'new' => $newCount, 'receipts' => $receiptUpdates]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
|
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||||
|
$db = Database::connect();
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE room_uid = :r AND direction = "in" AND is_read = 0')
|
||||||
|
->execute(['r' => $roomUid]);
|
||||||
|
// Group "seen" receipts aren't a thing in v1 — skip the relay
|
||||||
|
// notification. The local is_read flag is enough for badge UI.
|
||||||
|
chatJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND room_uid IS NULL AND direction = "in" AND is_read = 0')
|
||||||
|
->execute(['p' => $peer]);
|
||||||
|
|
||||||
|
// Tell the relay this peer's messages have been seen by us so the
|
||||||
|
// peer's next /api/chat/sent poll picks up the double-check state.
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if ($r['url'] && $r['user'] && $r['key']) {
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/seen');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['peer' => $peer]),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
// Failures are silent — the user already saw the messages
|
||||||
|
// locally, the relay state is just lagging.
|
||||||
|
}
|
||||||
|
chatJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/recipients' && $method === 'GET') {
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||||
|
chatJson(['ok' => false, 'error' => 'relay not configured']);
|
||||||
|
}
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/tenants');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200) chatJson(['ok' => false, 'error' => "relay returned HTTP $status"]);
|
||||||
|
$data = json_decode($response, true) ?: [];
|
||||||
|
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rooms CRUD (proxy to relay) ──────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
$rooms = $db->query('SELECT uid, name, owner_username, created_at FROM chat_rooms ORDER BY created_at DESC')->fetchAll();
|
||||||
|
$mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :u ORDER BY username');
|
||||||
|
foreach ($rooms as &$room) {
|
||||||
|
$mstmt->execute(['u' => $room['uid']]);
|
||||||
|
$room['members'] = array_column($mstmt->fetchAll(), 'username');
|
||||||
|
}
|
||||||
|
chatJson(['ok' => true, 'rooms' => $rooms]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($name === '') chatJson(['error' => 'name required'], 400);
|
||||||
|
if (count(array_filter($members, fn($m) => trim((string)$m) !== '')) === 0) {
|
||||||
|
chatJson(['error' => 'at least one other member required'], 400);
|
||||||
|
}
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||||
|
|
||||||
|
// Client-generated UID, same convention as collab buckets.
|
||||||
|
$uid = bin2hex(random_bytes(8));
|
||||||
|
$relayUrl = rtrim($r['url'], '/') . '/api/chat/rooms';
|
||||||
|
$ch = curl_init($relayUrl);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode(['uid' => $uid, 'name' => $name, 'members' => $members]),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200) {
|
||||||
|
$err = json_decode($response, true);
|
||||||
|
$detail = $err['error'] ?? "relay HTTP $status";
|
||||||
|
// 404 on this path almost always means the relay is running a
|
||||||
|
// pre-group-chat build. Surface that explicitly so the user
|
||||||
|
// doesn't go hunting for data issues.
|
||||||
|
if ($status === 404) {
|
||||||
|
$detail = "relay does not know /api/chat/rooms — it's likely running old code. Restart the relay after pulling the latest commits.";
|
||||||
|
}
|
||||||
|
chatJson(['ok' => false, 'error' => $detail, 'url' => $relayUrl, 'relay_status' => $status]);
|
||||||
|
}
|
||||||
|
$data = json_decode($response, true) ?: [];
|
||||||
|
// Mirror locally right away so the next conversations list shows
|
||||||
|
// the room without waiting for a poll.
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, datetime("now"), datetime("now"))')
|
||||||
|
->execute(['u' => $uid, 'n' => $name, 'o' => $r['user']]);
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$insMem = $db->prepare('INSERT INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
|
||||||
|
foreach (($data['members'] ?? []) as $mu) {
|
||||||
|
$insMem->execute(['u' => $uid, 'un' => $mu]);
|
||||||
|
}
|
||||||
|
chatJson(['ok' => true, 'uid' => $uid, 'members' => $data['members'] ?? []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$uid = $m[1];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($input),
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Relay-User: ' . $r['user'],
|
||||||
|
'X-Relay-Key: ' . $r['key'],
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
// The next poll will refresh the local room mirror with the new
|
||||||
|
// name / member list. No need to replay the update here.
|
||||||
|
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$uid = $m[1];
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status === 200) {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
}
|
||||||
|
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backfill older messages for a thread (scrollback / cold start) ──
|
||||||
|
|
||||||
|
if ($route === '/api/chat/backfill' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
|
$before = (int)($input['before'] ?? 0);
|
||||||
|
$limit = max(1, min(500, (int)($input['limit'] ?? 100)));
|
||||||
|
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||||
|
|
||||||
|
$r = chatRelaySettings();
|
||||||
|
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||||
|
|
||||||
|
$qs = http_build_query(array_filter([
|
||||||
|
'room_uid' => $roomUid ?: null,
|
||||||
|
'peer' => $peer ?: null,
|
||||||
|
'before' => $before > 0 ? $before : null,
|
||||||
|
'limit' => $limit,
|
||||||
|
], fn($v) => $v !== null));
|
||||||
|
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/backfill?' . $qs);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200) {
|
||||||
|
$err = json_decode($response, true);
|
||||||
|
chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
|
||||||
|
}
|
||||||
|
$data = json_decode($response, true) ?: [];
|
||||||
|
$messages = $data['messages'] ?? [];
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$newCount = 0;
|
||||||
|
$insert = $db->prepare('
|
||||||
|
INSERT OR IGNORE INTO chat_messages
|
||||||
|
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
|
||||||
|
VALUES (:p, :dir, :b, :rid, :ts, 1, :ru)
|
||||||
|
');
|
||||||
|
foreach ($messages as $m) {
|
||||||
|
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||||
|
if ($plain === '') continue;
|
||||||
|
$insert->execute([
|
||||||
|
'p' => $m['sender_username'] ?? '?',
|
||||||
|
'dir' => !empty($m['mine']) ? 'out' : 'in',
|
||||||
|
'b' => $plain,
|
||||||
|
'rid' => (int)$m['id'],
|
||||||
|
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||||
|
'ru' => !empty($m['room_uid']) ? $m['room_uid'] : null,
|
||||||
|
]);
|
||||||
|
if ($insert->rowCount() > 0) $newCount++;
|
||||||
|
}
|
||||||
|
chatJson(['ok' => true, 'backfilled' => $newCount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-thread settings (currently just the disposable flag) ──
|
||||||
|
|
||||||
|
if ($route === '/api/chat/thread-settings' && $method === 'GET') {
|
||||||
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
|
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
|
||||||
|
$stmt->execute(['p' => $peer]);
|
||||||
|
$disposable = (int)($stmt->fetchColumn() ?: 0);
|
||||||
|
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/thread-settings' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$peer = trim((string)($input['peer'] ?? ''));
|
||||||
|
$disposable = !empty($input['disposable']) ? 1 : 0;
|
||||||
|
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('INSERT OR REPLACE INTO chat_thread_settings (peer, disposable) VALUES (:p, :d)')
|
||||||
|
->execute(['p' => $peer, 'd' => $disposable]);
|
||||||
|
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/* ── Chat plugin styles ── */
|
||||||
|
|
||||||
|
#chat-flyout {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 48px;
|
||||||
|
right: 16px;
|
||||||
|
width: 340px;
|
||||||
|
height: 460px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-flyout.hidden { display: none; }
|
||||||
|
|
||||||
|
#chat-flyout-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combined caret + title trigger. Navigable (cursor + accent hover)
|
||||||
|
only when there's somewhere to go back to — toggled by JS via the
|
||||||
|
`.navigable` class. On the list it reads like a static label. */
|
||||||
|
.chat-header-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--t-bright);
|
||||||
|
cursor: default;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.chat-header-nav.navigable { cursor: pointer; color: var(--t-muted); }
|
||||||
|
.chat-header-nav.navigable:hover { color: var(--t-accent); }
|
||||||
|
|
||||||
|
.chat-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-view.hidden { display: none; }
|
||||||
|
|
||||||
|
/* ── Conversation list ── */
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When empty, collapse so the `.chat-empty` placeholder can fill and center
|
||||||
|
properly instead of being pushed into the lower half of the view. */
|
||||||
|
.chat-list:empty {
|
||||||
|
flex: 0 0 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-raised);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--t-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-peer {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-time {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-preview {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-list-preview.outgoing::before {
|
||||||
|
content: "› ";
|
||||||
|
color: var(--t-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list-row .chat-unread-badge {
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.chat-empty > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#chat-empty-start {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Thread view ── */
|
||||||
|
|
||||||
|
/* Horizontal peer strip above the thread header. Lets the user hop
|
||||||
|
between conversations without bouncing back to the list. Scrollbar
|
||||||
|
hidden — wheel-over scrolls horizontally (see chat.js). */
|
||||||
|
.chat-thread-peers {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.chat-thread-peers::-webkit-scrollbar { display: none; }
|
||||||
|
.chat-thread-peers:empty { display: none; }
|
||||||
|
|
||||||
|
.chat-peer-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button, 3px);
|
||||||
|
background: var(--t-surface);
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-peer-tab:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.chat-peer-tab.active {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
.chat-peer-tab .chat-peer-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
|
||||||
|
|
||||||
|
.chat-thread-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--t-font-mono, monospace);
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
/* Pick up the user's card-radius design token so chat bubbles look
|
||||||
|
consistent with the rest of the surface (sharp / rounded / pill). */
|
||||||
|
border-radius: var(--ui-radius-card, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble.in {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-bright);
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble.out {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble-time {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble.out + .chat-bubble-time { align-self: flex-end; }
|
||||||
|
.chat-bubble.in + .chat-bubble-time { align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Read receipts: clock = sent (relay not yet acked), single check =
|
||||||
|
delivered (recipient polled), double check = seen (recipient opened
|
||||||
|
the thread). Single/double share the same icon size as the timestamp. */
|
||||||
|
.chat-receipt {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.chat-receipt.pending { color: var(--t-dim); }
|
||||||
|
.chat-receipt.delivered { color: var(--t-muted); }
|
||||||
|
.chat-receipt.seen { color: var(--t-accent); }
|
||||||
|
|
||||||
|
#chat-thread-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-thread-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--t-font-mono, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-thread-input:focus {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-thread-send {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-thread-send:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pick recipient view ── */
|
||||||
|
|
||||||
|
#chat-pick-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pick-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pick-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pick-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pick-row.selected {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.chat-pick-row .chat-pick-check {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--t-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.chat-pick-row:not(.selected) .chat-pick-check { visibility: hidden; }
|
||||||
|
|
||||||
|
/* Picker mode switch (1:1 vs. group). */
|
||||||
|
.chat-pick-mode {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-pick-mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.chat-pick-mode-btn:hover { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
.chat-pick-mode-btn.active {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group-name {
|
||||||
|
margin: 8px 10px 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--t-font-mono, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.chat-group-name:focus { border-color: var(--t-accent); }
|
||||||
|
.chat-group-name.hidden { display: none; }
|
||||||
|
|
||||||
|
.chat-group-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-group-actions.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Room-info view: member list + owner actions. */
|
||||||
|
#chat-room-info-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-room-info-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .section-label {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.chat-room-member-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.chat-room-member-row .owner-badge {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
background: var(--t-raised);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--t-accent);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.chat-room-info-body .btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.chat-room-info-body .btn.danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Author name above group-thread bubbles. Shown only when the author
|
||||||
|
changes, so consecutive bubbles from the same person aren't repeated.
|
||||||
|
Left-aligned so it sits above the left-aligned incoming bubble. */
|
||||||
|
.chat-bubble-author {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--t-accent2, var(--t-accent));
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0 2px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer-tab variant for rooms — group icon instead of initial. */
|
||||||
|
.chat-peer-tab .chat-peer-tab-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disposable / info icons in flyout header. */
|
||||||
|
#chat-thread-disposable.active {
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer button ── */
|
||||||
|
|
||||||
|
#chat-footer-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
color: var(--t-muted);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn .chat-footer-label {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn.has-unread {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn .chat-unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-accent);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn.has-unread .chat-unread-dot {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chatBlink {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-accent);
|
||||||
|
box-shadow: 0 0 0 0 var(--t-accent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
color: var(--t-bright);
|
||||||
|
box-shadow: 0 0 8px 1px var(--t-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-footer-btn.blinking {
|
||||||
|
animation: chatBlink 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
/* Ignore the global anim-speed override — the unread blink has a
|
||||||
|
deliberate cadence that becomes an unreadable strobe when sped up. */
|
||||||
|
body.anim-fast #chat-footer-btn.blinking,
|
||||||
|
body.anim-slow #chat-footer-btn.blinking {
|
||||||
|
animation: chatBlink 1.2s ease-in-out infinite !important;
|
||||||
|
animation-duration: 1.2s !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- Chat plugin: bottom-right flyout. Hidden by default; toggled by the
|
||||||
|
footer button injected via chat.js. -->
|
||||||
|
<div id="chat-flyout" class="hidden">
|
||||||
|
<div id="chat-flyout-header">
|
||||||
|
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
||||||
|
<!-- Caret + "chat" label are a single trigger with one tooltip.
|
||||||
|
The caret shows only when there's somewhere to go back to
|
||||||
|
(thread / pick / room-info); on the list itself the click
|
||||||
|
is a no-op and the button styles itself as inert. -->
|
||||||
|
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
|
||||||
|
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
|
||||||
|
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
|
||||||
|
</button>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
||||||
|
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info 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" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation list (default view) -->
|
||||||
|
<div id="chat-list-view" class="chat-view">
|
||||||
|
<div id="chat-list" class="chat-list"></div>
|
||||||
|
<div id="chat-empty" class="hidden chat-empty">
|
||||||
|
<i class="ph ph-chats text-2xl text-t-dim mb-2"></i>
|
||||||
|
<p class="font-pixel text-[10px] text-t-dim cursor-blink">no conversations yet</p>
|
||||||
|
<button id="chat-empty-start" class="mt-3 px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Start chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation thread view -->
|
||||||
|
<div id="chat-thread-view" class="chat-view hidden">
|
||||||
|
<div id="chat-thread-peers" class="chat-thread-peers"></div>
|
||||||
|
<div id="chat-thread-messages" class="chat-thread-messages"></div>
|
||||||
|
<form id="chat-thread-form">
|
||||||
|
<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" data-tip="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New-conversation peer picker / group creation.
|
||||||
|
Shared view: a mode toggle at top switches between picking one
|
||||||
|
peer (1:1) and building a group (name + multi-select). -->
|
||||||
|
<div id="chat-pick-view" class="chat-view hidden">
|
||||||
|
<div id="chat-pick-header">
|
||||||
|
<button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
|
<span id="chat-pick-title" class="font-pixel text-[10px] text-t-bright flex-1">new conversation</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-pick-mode" class="chat-pick-mode">
|
||||||
|
<button class="chat-pick-mode-btn active" data-mode="peer" type="button">1:1</button>
|
||||||
|
<button class="chat-pick-mode-btn" data-mode="group" type="button">group</button>
|
||||||
|
</div>
|
||||||
|
<input id="chat-group-name" class="chat-group-name hidden" type="text" autocomplete="off" placeholder="group name..." maxlength="80">
|
||||||
|
<div id="chat-pick-list" class="chat-pick-list"></div>
|
||||||
|
<div id="chat-group-actions" class="chat-group-actions hidden">
|
||||||
|
<span id="chat-group-count" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button id="chat-group-create" type="button" class="px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Room info / member management view.
|
||||||
|
Shown when the user taps the info icon in a room thread. -->
|
||||||
|
<div id="chat-room-info-view" class="chat-view hidden">
|
||||||
|
<div id="chat-room-info-header">
|
||||||
|
<button id="chat-room-info-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
|
<i class="ph ph-users-three text-t-accent text-sm"></i>
|
||||||
|
<span id="chat-room-info-name" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-room-info-body" class="chat-room-info-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
// ── Chat Plugin Frontend ──
|
||||||
|
//
|
||||||
|
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
|
||||||
|
// (faster while open, slower while closed). Blinks the footer button
|
||||||
|
// when new incoming messages arrive while the flyout is closed.
|
||||||
|
//
|
||||||
|
// Threads come in two kinds:
|
||||||
|
// peer — classic 1:1 with a single relay tenant. Disposable flag per
|
||||||
|
// thread drops messages from the relay after delivery.
|
||||||
|
// room — named group chat with a member list. Always persistent on
|
||||||
|
// the relay so every device / late joiner has full history.
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let conversations = [];
|
||||||
|
// Null = list view. Otherwise: { kind: 'peer'|'room', key, label }.
|
||||||
|
let currentThread = null;
|
||||||
|
let currentMessages = [];
|
||||||
|
let pollHandle = null;
|
||||||
|
let pollIntervalMs = 20000;
|
||||||
|
let recipientCache = null; // { tenants: [...], self: '...' }
|
||||||
|
let initialPollDone = false;
|
||||||
|
|
||||||
|
// Group creation pick-view state. Reset on every pick-view open.
|
||||||
|
let pickMode = 'peer'; // 'peer' or 'group'
|
||||||
|
let selectedMembers = new Set();
|
||||||
|
|
||||||
|
const POLL_OPEN_MS = 8000;
|
||||||
|
const POLL_CLOSED_MS = 20000;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChatTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const diffDays = (now - d) / 86400000;
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen() {
|
||||||
|
return !$('chat-flyout')?.classList.contains('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadFromItem(item) {
|
||||||
|
return { kind: item.kind || 'peer', key: item.key ?? item.peer, label: item.label ?? item.peer };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameThread(a, b) {
|
||||||
|
return a && b && a.kind === b.kind && a.key === b.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadBody(thread) {
|
||||||
|
return thread.kind === 'room' ? { room_uid: thread.key } : { peer: thread.key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadStorageKey(thread) {
|
||||||
|
return `${thread.kind}:${thread.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStorageKey(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const i = str.indexOf(':');
|
||||||
|
if (i < 0) return { kind: 'peer', key: str, label: str };
|
||||||
|
return { kind: str.slice(0, i), key: str.slice(i + 1), label: str.slice(i + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSelfUsername() {
|
||||||
|
if (recipientCache?.self) return recipientCache.self;
|
||||||
|
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
||||||
|
if (res?.ok) recipientCache = res;
|
||||||
|
return recipientCache?.self || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer button ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function injectFooterButton() {
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
if (!footer) return;
|
||||||
|
if (document.getElementById('chat-footer-btn')) return;
|
||||||
|
|
||||||
|
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
|
||||||
|
if (!rightGroup) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'chat-footer-btn';
|
||||||
|
btn.title = 'chat';
|
||||||
|
btn.innerHTML = `
|
||||||
|
<i class="ph ph-chats-circle text-sm"></i>
|
||||||
|
<span class="chat-footer-label">chat</span>
|
||||||
|
<span id="chat-unread-count" class="chat-unread-dot"></span>
|
||||||
|
`;
|
||||||
|
btn.addEventListener('click', toggleFlyout);
|
||||||
|
rightGroup.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUnreadBadge(n) {
|
||||||
|
const btn = $('chat-footer-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (n > 0) {
|
||||||
|
btn.classList.add('has-unread');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('has-unread');
|
||||||
|
btn.classList.remove('blinking');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBlink() {
|
||||||
|
if (isOpen()) return;
|
||||||
|
$('chat-footer-btn')?.classList.add('blinking');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBlink() {
|
||||||
|
$('chat-footer-btn')?.classList.remove('blinking');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flyout open / close ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function toggleFlyout() {
|
||||||
|
if (isOpen()) closeFlyout();
|
||||||
|
else await openFlyout();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFlyout() {
|
||||||
|
$('chat-flyout').classList.remove('hidden');
|
||||||
|
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
|
||||||
|
stopBlink();
|
||||||
|
pollIntervalMs = POLL_OPEN_MS;
|
||||||
|
restartPoll();
|
||||||
|
await loadConversations();
|
||||||
|
await pollOnce();
|
||||||
|
await loadConversations();
|
||||||
|
|
||||||
|
// Initial view: newest unread thread after a blink, else the last
|
||||||
|
// thread the user opened (peer or room), else the list.
|
||||||
|
let target = null;
|
||||||
|
if (wasBlinking) {
|
||||||
|
const unread = conversations
|
||||||
|
.filter(c => parseInt(c.unread) > 0)
|
||||||
|
.sort((a, b) => String(b.last_at || '').localeCompare(String(a.last_at || '')));
|
||||||
|
if (unread.length) target = threadFromItem(unread[0]);
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
const remembered = parseStorageKey(localStorage.getItem('chat-last-thread'));
|
||||||
|
if (remembered && conversations.some(c => c.kind === remembered.kind && c.key === remembered.key)) {
|
||||||
|
const item = conversations.find(c => c.kind === remembered.kind && c.key === remembered.key);
|
||||||
|
target = threadFromItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target) await openConversation(target);
|
||||||
|
else showListView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFlyout() {
|
||||||
|
$('chat-flyout').classList.add('hidden');
|
||||||
|
pollIntervalMs = POLL_CLOSED_MS;
|
||||||
|
restartPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Views ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hideAllViews() {
|
||||||
|
['chat-list-view', 'chat-thread-view', 'chat-pick-view', 'chat-room-info-view']
|
||||||
|
.forEach(id => $(id)?.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the caret + make the "chat" trigger look/feel clickable only
|
||||||
|
// when there's actually somewhere to go back to.
|
||||||
|
function setNavState(canBack) {
|
||||||
|
$('chat-header-back-caret')?.classList.toggle('hidden', !canBack);
|
||||||
|
$('chat-header-nav')?.classList.toggle('navigable', canBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showListView() {
|
||||||
|
hideAllViews();
|
||||||
|
$('chat-list-view').classList.remove('hidden');
|
||||||
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
|
setNavState(false);
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
|
currentThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showThreadView(thread) {
|
||||||
|
hideAllViews();
|
||||||
|
$('chat-thread-view').classList.remove('hidden');
|
||||||
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
|
setNavState(true);
|
||||||
|
currentThread = thread;
|
||||||
|
// Room-only: info icon. 1:1-only: disposable icon.
|
||||||
|
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
|
||||||
|
$('chat-thread-disposable')?.classList.toggle('hidden', thread.kind !== 'peer');
|
||||||
|
if (thread.kind === 'peer') refreshDisposableIcon(thread.key);
|
||||||
|
renderPeerTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPickView() {
|
||||||
|
hideAllViews();
|
||||||
|
$('chat-pick-view').classList.remove('hidden');
|
||||||
|
setNavState(false);
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRoomInfoView() {
|
||||||
|
hideAllViews();
|
||||||
|
$('chat-room-info-view').classList.remove('hidden');
|
||||||
|
setNavState(false);
|
||||||
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversation list ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
const res = await fetch('api/chat/conversations').then(r => r.json()).catch(() => null);
|
||||||
|
if (!res) return;
|
||||||
|
conversations = res.conversations || [];
|
||||||
|
setUnreadBadge(res.total_unread || 0);
|
||||||
|
renderConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversations() {
|
||||||
|
const list = $('chat-list');
|
||||||
|
const empty = $('chat-empty');
|
||||||
|
if (!list || !empty) return;
|
||||||
|
renderPeerTabs();
|
||||||
|
if (!conversations.length) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
list.innerHTML = conversations.map(c => {
|
||||||
|
const isRoom = c.kind === 'room';
|
||||||
|
const label = c.label || c.key || '?';
|
||||||
|
const initial = label.charAt(0).toUpperCase();
|
||||||
|
const avatar = isRoom
|
||||||
|
? '<div class="chat-list-avatar"><i class="ph ph-users-three"></i></div>'
|
||||||
|
: `<div class="chat-list-avatar">${escapeHtml(initial)}</div>`;
|
||||||
|
const unreadBadge = parseInt(c.unread) > 0
|
||||||
|
? `<span class="chat-unread-badge">${c.unread}</span>` : '';
|
||||||
|
const previewClass = (!isRoom && c.last_direction === 'out')
|
||||||
|
? 'chat-list-preview outgoing'
|
||||||
|
: 'chat-list-preview';
|
||||||
|
const preview = isRoom && c.last_author
|
||||||
|
? `${escapeHtml(c.last_author)}: ${escapeHtml((c.last_body || '').slice(0, 70))}`
|
||||||
|
: escapeHtml((c.last_body || '').slice(0, 80));
|
||||||
|
return `
|
||||||
|
<div class="chat-list-row" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}">
|
||||||
|
${avatar}
|
||||||
|
<div class="chat-list-info">
|
||||||
|
<div class="chat-list-peer">
|
||||||
|
<span>${escapeHtml(label)}</span>
|
||||||
|
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="${previewClass}">${preview}</div>
|
||||||
|
</div>
|
||||||
|
${unreadBadge}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.chat-list-row').forEach(row => {
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
const item = conversations.find(c => c.kind === row.dataset.kind && c.key === row.dataset.key);
|
||||||
|
if (item) openConversation(threadFromItem(item));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
|
||||||
|
// Horizontal row above the thread so the user can hop between
|
||||||
|
// conversations without going back to the list. Hidden scrollbar;
|
||||||
|
// vertical wheel delta is redirected to horizontal scroll.
|
||||||
|
|
||||||
|
function renderPeerTabs() {
|
||||||
|
const strip = $('chat-thread-peers');
|
||||||
|
if (!strip) return;
|
||||||
|
const tabs = conversations.slice();
|
||||||
|
// Fold the current thread in even if it's brand-new (e.g. just
|
||||||
|
// created a room and haven't synced conversations yet), so the
|
||||||
|
// active tab is always visible.
|
||||||
|
if (currentThread && !tabs.some(c => c.kind === currentThread.kind && c.key === currentThread.key)) {
|
||||||
|
tabs.unshift({ kind: currentThread.kind, key: currentThread.key, label: currentThread.label, unread: 0 });
|
||||||
|
}
|
||||||
|
if (!tabs.length) { strip.innerHTML = ''; return; }
|
||||||
|
strip.innerHTML = tabs.map(c => {
|
||||||
|
const isRoom = c.kind === 'room';
|
||||||
|
const active = currentThread && currentThread.kind === c.kind && currentThread.key === c.key ? ' active' : '';
|
||||||
|
const notActive = !active;
|
||||||
|
const dot = parseInt(c.unread) > 0 && notActive
|
||||||
|
? '<span class="chat-peer-dot"></span>' : '';
|
||||||
|
const icon = isRoom
|
||||||
|
? '<i class="ph ph-users-three chat-peer-tab-icon"></i>'
|
||||||
|
: '';
|
||||||
|
const label = c.label || c.key || '?';
|
||||||
|
return `<button class="chat-peer-tab${active}" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}" title="${escapeHtml(label)}">${dot}${icon}<span>${escapeHtml(label)}</span></button>`;
|
||||||
|
}).join('');
|
||||||
|
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const thread = { kind: tab.dataset.kind, key: tab.dataset.key, label: tab.querySelector('span')?.textContent || tab.dataset.key };
|
||||||
|
if (!sameThread(currentThread, thread)) openConversation(thread);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const active = strip.querySelector('.chat-peer-tab.active');
|
||||||
|
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function wirePeerStripWheel() {
|
||||||
|
const strip = $('chat-thread-peers');
|
||||||
|
if (!strip || strip.dataset.wheelWired) return;
|
||||||
|
strip.dataset.wheelWired = '1';
|
||||||
|
strip.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (strip.scrollWidth <= strip.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
strip.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openConversation(thread) {
|
||||||
|
showThreadView(thread);
|
||||||
|
localStorage.setItem('chat-last-thread', threadStorageKey(thread));
|
||||||
|
await loadThread(thread);
|
||||||
|
// Backfill older messages from the relay on open so a second device /
|
||||||
|
// cold cache catches up. Fire-and-forget: the re-render happens via
|
||||||
|
// the subsequent conversations refresh and the next poll tick.
|
||||||
|
backfillThread(thread).catch(() => {});
|
||||||
|
await hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(thread) }).catch(() => {});
|
||||||
|
loadConversations();
|
||||||
|
setTimeout(() => $('chat-thread-input')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThread(thread) {
|
||||||
|
const qs = thread.kind === 'room'
|
||||||
|
? 'room_uid=' + encodeURIComponent(thread.key)
|
||||||
|
: 'peer=' + encodeURIComponent(thread.key);
|
||||||
|
const res = await fetch('api/chat/messages?' + qs).then(r => r.json()).catch(() => null);
|
||||||
|
if (!res) return;
|
||||||
|
currentMessages = res.messages || [];
|
||||||
|
await renderThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillThread(thread) {
|
||||||
|
const body = threadBody(thread);
|
||||||
|
// Oldest id we have locally is the lower bound for the backfill —
|
||||||
|
// we want messages BEFORE it.
|
||||||
|
const lowest = currentMessages.reduce((min, m) => {
|
||||||
|
const id = Number(m.id || 0);
|
||||||
|
return min === 0 || (id > 0 && id < min) ? id : min;
|
||||||
|
}, 0);
|
||||||
|
const res = await hyprcrd.api('api/chat/backfill', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...body, before: lowest, limit: 100 },
|
||||||
|
}).catch(() => null);
|
||||||
|
if (res?.ok && (res.backfilled || 0) > 0 && sameThread(thread, currentThread)) {
|
||||||
|
await loadThread(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderThread() {
|
||||||
|
const wrap = $('chat-thread-messages');
|
||||||
|
if (!wrap) return;
|
||||||
|
const self = await getSelfUsername();
|
||||||
|
const isRoom = currentThread?.kind === 'room';
|
||||||
|
let lastAuthor = null;
|
||||||
|
wrap.innerHTML = currentMessages.map(m => {
|
||||||
|
// For groups: author == self → "out" bubble regardless of the
|
||||||
|
// stored direction (which reflects which device inserted the row).
|
||||||
|
const mine = isRoom ? (m.peer === self) : (m.direction === 'out');
|
||||||
|
const cls = mine ? 'out' : 'in';
|
||||||
|
let receipt = '';
|
||||||
|
if (!isRoom && mine) {
|
||||||
|
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
|
||||||
|
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
|
||||||
|
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
|
||||||
|
}
|
||||||
|
let authorLine = '';
|
||||||
|
if (isRoom && !mine && m.peer !== lastAuthor) {
|
||||||
|
authorLine = `<div class="chat-bubble-author">${escapeHtml(m.peer || '')}</div>`;
|
||||||
|
}
|
||||||
|
lastAuthor = m.peer;
|
||||||
|
return `
|
||||||
|
${authorLine}
|
||||||
|
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
|
||||||
|
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(e) {
|
||||||
|
e?.preventDefault();
|
||||||
|
const input = $('chat-thread-input');
|
||||||
|
const body = input.value.trim();
|
||||||
|
if (!body || !currentThread) return;
|
||||||
|
const sendBtn = $('chat-thread-send');
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
input.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/chat/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...threadBody(currentThread), body },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = [
|
||||||
|
res.error || 'unknown',
|
||||||
|
res.url ? `URL: ${res.url}` : null,
|
||||||
|
res.response_body ? `Body: ${res.response_body}` : null,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'send failed',
|
||||||
|
message: detail,
|
||||||
|
danger: true,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
cancelLabel: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
await loadThread(currentThread);
|
||||||
|
await loadConversations();
|
||||||
|
} catch (err) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'send error',
|
||||||
|
message: String(err?.message || err),
|
||||||
|
danger: true,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
cancelLabel: '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
input.disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pick recipient / group creation ───────────────────────────────
|
||||||
|
|
||||||
|
async function openPickView() {
|
||||||
|
showPickView();
|
||||||
|
pickMode = 'peer';
|
||||||
|
selectedMembers = new Set();
|
||||||
|
applyPickMode();
|
||||||
|
$('chat-group-name').value = '';
|
||||||
|
|
||||||
|
const list = $('chat-pick-list');
|
||||||
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||||
|
|
||||||
|
if (!recipientCache) {
|
||||||
|
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
|
||||||
|
if (!res?.ok) {
|
||||||
|
list.innerHTML = `<div class="px-3 py-3 text-[10px] text-t-danger font-pixel">${escapeHtml(res?.error || 'failed to load recipients')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipientCache = res;
|
||||||
|
}
|
||||||
|
renderPickList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPickMode() {
|
||||||
|
const nameInput = $('chat-group-name');
|
||||||
|
const actions = $('chat-group-actions');
|
||||||
|
const title = $('chat-pick-title');
|
||||||
|
document.querySelectorAll('.chat-pick-mode-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.mode === pickMode);
|
||||||
|
});
|
||||||
|
if (pickMode === 'group') {
|
||||||
|
nameInput.classList.remove('hidden');
|
||||||
|
actions.classList.remove('hidden');
|
||||||
|
if (title) title.textContent = 'new group';
|
||||||
|
updateGroupCount();
|
||||||
|
} else {
|
||||||
|
nameInput.classList.add('hidden');
|
||||||
|
actions.classList.add('hidden');
|
||||||
|
if (title) title.textContent = 'new conversation';
|
||||||
|
}
|
||||||
|
renderPickList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPickList() {
|
||||||
|
const list = $('chat-pick-list');
|
||||||
|
if (!list || !recipientCache) return;
|
||||||
|
const self = recipientCache.self;
|
||||||
|
const others = (recipientCache.tenants || []).filter(t => t.username !== self);
|
||||||
|
if (!others.length) {
|
||||||
|
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = others.map(t => {
|
||||||
|
const selected = selectedMembers.has(t.username);
|
||||||
|
const selCls = (pickMode === 'group' && selected) ? ' selected' : '';
|
||||||
|
const check = pickMode === 'group'
|
||||||
|
? `<i class="ph ph-check chat-pick-check"></i>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="chat-pick-row${selCls}" data-peer="${escapeHtml(t.username)}">
|
||||||
|
<i class="ph ph-user-circle text-sm"></i>
|
||||||
|
<span>${escapeHtml(t.username)}</span>
|
||||||
|
${check}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.chat-pick-row').forEach(row => {
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
const u = row.dataset.peer;
|
||||||
|
if (pickMode === 'group') {
|
||||||
|
if (selectedMembers.has(u)) selectedMembers.delete(u);
|
||||||
|
else selectedMembers.add(u);
|
||||||
|
renderPickList();
|
||||||
|
updateGroupCount();
|
||||||
|
} else {
|
||||||
|
openConversation({ kind: 'peer', key: u, label: u });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroupCount() {
|
||||||
|
const el = $('chat-group-count');
|
||||||
|
if (!el) return;
|
||||||
|
const n = selectedMembers.size;
|
||||||
|
el.textContent = n ? `${n} member${n === 1 ? '' : 's'} selected` : 'pick at least one member';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
const name = $('chat-group-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'missing name', message: 'Give the group a name.', okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedMembers.size) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'no members', message: 'Pick at least one other member.', okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await hyprcrd.api('api/chat/rooms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, members: [...selectedMembers] },
|
||||||
|
});
|
||||||
|
if (!res?.ok) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'create failed',
|
||||||
|
message: res?.error || 'unknown error',
|
||||||
|
danger: true,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
cancelLabel: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadConversations();
|
||||||
|
openConversation({ kind: 'room', key: res.uid, label: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room info / member management ──────────────────────────────
|
||||||
|
|
||||||
|
async function openRoomInfo() {
|
||||||
|
if (currentThread?.kind !== 'room') return;
|
||||||
|
const savedThread = currentThread;
|
||||||
|
showRoomInfoView();
|
||||||
|
$('chat-room-info-name').textContent = savedThread.label || savedThread.key;
|
||||||
|
const body = $('chat-room-info-body');
|
||||||
|
body.innerHTML = '<div class="text-[10px] text-t-dim font-pixel">loading...</div>';
|
||||||
|
|
||||||
|
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
|
||||||
|
const room = (res?.rooms || []).find(r => r.uid === savedThread.key);
|
||||||
|
const self = await getSelfUsername();
|
||||||
|
if (!room) {
|
||||||
|
body.innerHTML = '<div class="text-[10px] text-t-danger font-pixel">room not found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isOwner = room.owner_username === self;
|
||||||
|
const others = (res?.tenants || recipientCache?.tenants || [])
|
||||||
|
.filter(t => t.username !== self && !(room.members || []).includes(t.username));
|
||||||
|
|
||||||
|
const memberRows = (room.members || []).map(u => {
|
||||||
|
const ownerTag = u === room.owner_username ? '<span class="owner-badge">owner</span>' : '';
|
||||||
|
const removeBtn = (isOwner && u !== room.owner_username)
|
||||||
|
? `<button class="btn danger" data-action="remove" data-user="${escapeHtml(u)}" style="margin-left:auto;padding:2px 8px;">remove</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="chat-room-member-row">
|
||||||
|
<i class="ph ph-user-circle text-t-accent"></i>
|
||||||
|
<span>${escapeHtml(u)}</span>
|
||||||
|
${ownerTag}
|
||||||
|
${removeBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length
|
||||||
|
? `<div class="section-label">Add members</div>
|
||||||
|
<div id="chat-room-add-list">${
|
||||||
|
(recipientCache?.tenants || [])
|
||||||
|
.filter(t => t.username !== self && !(room.members || []).includes(t.username))
|
||||||
|
.map(t => `<div class="chat-room-member-row"><i class="ph ph-user-circle"></i><span>${escapeHtml(t.username)}</span><button class="btn" style="margin-left:auto;padding:2px 8px;" data-action="add" data-user="${escapeHtml(t.username)}">add</button></div>`)
|
||||||
|
.join('') || '<div class="text-[10px] text-t-dim font-pixel">everyone on the relay is already in</div>'
|
||||||
|
}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="section-label">Members (${(room.members || []).length})</div>
|
||||||
|
${memberRows || '<div class="text-[10px] text-t-dim">no members</div>'}
|
||||||
|
</div>
|
||||||
|
${addSection}
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||||
|
${isOwner
|
||||||
|
? '<button class="btn danger" data-action="delete" style="flex:1">Delete room</button>'
|
||||||
|
: '<button class="btn danger" data-action="leave" style="flex:1">Leave room</button>'
|
||||||
|
}
|
||||||
|
<button class="btn" data-action="back" style="flex:1">Back to chat</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.querySelectorAll('button[data-action]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => handleRoomAction(btn.dataset.action, btn.dataset.user, savedThread));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoomAction(action, user, thread) {
|
||||||
|
const uid = thread.key;
|
||||||
|
if (action === 'back') {
|
||||||
|
openConversation(thread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'remove' || action === 'add') {
|
||||||
|
// Fetch current room, mutate members, PATCH.
|
||||||
|
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
|
||||||
|
const room = (res?.rooms || []).find(r => r.uid === uid);
|
||||||
|
if (!room) return;
|
||||||
|
let members = [...(room.members || [])];
|
||||||
|
if (action === 'remove') members = members.filter(m => m !== user);
|
||||||
|
else if (!members.includes(user)) members.push(user);
|
||||||
|
// Relay adds owner automatically, but we forward the full list
|
||||||
|
// minus the owner (owner ALWAYS stays on relay side).
|
||||||
|
const patchRes = await hyprcrd.api(`api/chat/rooms/${uid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { members },
|
||||||
|
});
|
||||||
|
if (!patchRes?.ok) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'update failed', message: patchRes?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
}
|
||||||
|
await pollOnce(); // refresh local rooms mirror
|
||||||
|
await openRoomInfo(); // re-render
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'leave' || action === 'delete') {
|
||||||
|
const confirm = await hyprcrd.dialog.show({
|
||||||
|
title: action === 'delete' ? 'Delete room?' : 'Leave room?',
|
||||||
|
message: action === 'delete'
|
||||||
|
? 'All members lose access and the message history is wiped.'
|
||||||
|
: 'You will stop receiving messages in this room.',
|
||||||
|
danger: true,
|
||||||
|
okLabel: action === 'delete' ? 'Delete' : 'Leave',
|
||||||
|
});
|
||||||
|
if (!confirm) return;
|
||||||
|
const res = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'DELETE' });
|
||||||
|
if (!res?.ok) {
|
||||||
|
await hyprcrd.dialog.show({ title: 'failed', message: res?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollOnce();
|
||||||
|
await loadConversations();
|
||||||
|
showListView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disposable toggle (1:1) ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function refreshDisposableIcon(peer) {
|
||||||
|
const btn = $('chat-thread-disposable');
|
||||||
|
if (!btn) return;
|
||||||
|
const res = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json()).catch(() => null);
|
||||||
|
const on = !!res?.disposable;
|
||||||
|
btn.classList.toggle('active', on);
|
||||||
|
btn.setAttribute('data-tip', on
|
||||||
|
? 'disposable: new messages drop after delivery — click to persist'
|
||||||
|
: 'persistent: messages live on the relay — click to make disposable');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDisposable() {
|
||||||
|
if (currentThread?.kind !== 'peer') return;
|
||||||
|
const peer = currentThread.key;
|
||||||
|
const cur = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
|
.then(r => r.json()).catch(() => ({ disposable: 0 }));
|
||||||
|
const next = cur.disposable ? 0 : 1;
|
||||||
|
await hyprcrd.api('api/chat/thread-settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { peer, disposable: next },
|
||||||
|
});
|
||||||
|
refreshDisposableIcon(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
const icon = $('chat-refresh-icon');
|
||||||
|
if (icon && isOpen()) icon.classList.add('card-spinner');
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null);
|
||||||
|
if (!res?.ok) return;
|
||||||
|
const newCount = res.new || 0;
|
||||||
|
const receiptUpdates = res.receipts || 0;
|
||||||
|
if (newCount > 0) {
|
||||||
|
await loadConversations();
|
||||||
|
if (isOpen() && currentThread) {
|
||||||
|
await loadThread(currentThread);
|
||||||
|
hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(currentThread) })
|
||||||
|
.then(() => loadConversations()).catch(() => {});
|
||||||
|
}
|
||||||
|
if (!isOpen()) startBlink();
|
||||||
|
} else if (receiptUpdates > 0 && isOpen() && currentThread) {
|
||||||
|
await loadThread(currentThread);
|
||||||
|
} else if (initialPollDone === false) {
|
||||||
|
await loadConversations();
|
||||||
|
}
|
||||||
|
initialPollDone = true;
|
||||||
|
} finally {
|
||||||
|
if (icon) icon.classList.remove('card-spinner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPoll() {
|
||||||
|
if (pollHandle) clearInterval(pollHandle);
|
||||||
|
pollHandle = setInterval(pollOnce, pollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!$('chat-flyout')) return;
|
||||||
|
|
||||||
|
injectFooterButton();
|
||||||
|
|
||||||
|
$('chat-close-btn').addEventListener('click', closeFlyout);
|
||||||
|
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||||
|
$('chat-new-btn').addEventListener('click', openPickView);
|
||||||
|
$('chat-empty-start')?.addEventListener('click', openPickView);
|
||||||
|
// Caret + "chat" label are one button with one tooltip. Click is a
|
||||||
|
// no-op when the caret is hidden (already on the list) — the CSS
|
||||||
|
// .navigable class tracks whether the button looks clickable.
|
||||||
|
$('chat-header-nav')?.addEventListener('click', () => {
|
||||||
|
if (!$('chat-header-nav')?.classList.contains('navigable')) return;
|
||||||
|
showListView();
|
||||||
|
loadConversations();
|
||||||
|
});
|
||||||
|
$('chat-pick-back').addEventListener('click', showListView);
|
||||||
|
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||||
|
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
|
||||||
|
$('chat-thread-disposable')?.addEventListener('click', toggleDisposable);
|
||||||
|
$('chat-room-info-back')?.addEventListener('click', () => {
|
||||||
|
if (currentThread) openConversation(currentThread);
|
||||||
|
else showListView();
|
||||||
|
});
|
||||||
|
$('chat-group-create')?.addEventListener('click', createRoom);
|
||||||
|
document.querySelectorAll('.chat-pick-mode-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
pickMode = btn.dataset.mode;
|
||||||
|
applyPickMode();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wirePeerStripWheel();
|
||||||
|
|
||||||
|
$('chat-flyout').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
loadConversations().catch(() => {});
|
||||||
|
const startPolling = () => {
|
||||||
|
pollOnce().catch(() => {});
|
||||||
|
restartPoll();
|
||||||
|
};
|
||||||
|
if (hyprcrd.util.csrf?.()) {
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
hyprcrd.hooks.on('csrf.ready', startPolling);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||||
|
} else {
|
||||||
|
pollOnce().catch(() => {});
|
||||||
|
restartPoll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
hyprcrd.registerPlugin?.({
|
||||||
|
name: 'chat',
|
||||||
|
version: '1.1.0',
|
||||||
|
description: 'Encrypted chat between relay tenants (1:1 + group rooms)',
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Encrypted chat between relay tenants. Footer flyout that blinks on new messages.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["chat.js"],
|
||||||
|
"css": ["chat.css"],
|
||||||
|
"html": ["chat.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# collab-buckets plugin
|
||||||
|
|
||||||
|
Collaborative buckets shared between relay tenants. Each bucket has an owner
|
||||||
|
and a set of members; membership changes and metadata updates propagate
|
||||||
|
through the existing relay. Collab buckets are **first-class rows in the
|
||||||
|
normal bucket bar** — the plugin just owns the sync plus an unread
|
||||||
|
indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.
|
||||||
|
|
||||||
|
**Phase 1 through 5 shipped.** Metadata + membership sync, bucket-bar
|
||||||
|
integration, full card sync (create / edit / delete, LWW conflict
|
||||||
|
resolution with local Conflicts-bucket stashing, encrypted round-trip
|
||||||
|
via the relay's decrypt-reencrypt hop), advisory per-card edit locks
|
||||||
|
with a 🔒 badge on cards another user is currently editing,
|
||||||
|
bucket-ownership transfer through a members & owner modal, and the
|
||||||
|
Phase 5 hardening pass: **"Make collaborative…"** upgrade path for
|
||||||
|
existing local buckets, **undo** for local bucket delete/leave,
|
||||||
|
**resilient create** via a local outbox that queues intents when the
|
||||||
|
relay is unreachable and retries on the next poll, and
|
||||||
|
**rate-limiting** on all collab endpoints. Remaining nice-to-haves
|
||||||
|
(position propagation, presence, read receipts, inline member picker,
|
||||||
|
owner approval flow) are in `TODO.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Create a collab bucket** from the normal `+ Bucket` button in the
|
||||||
|
bucket bar. The plugin intercepts the button click (via
|
||||||
|
`window._collabBucketsCreateDialog`) and shows a modal with an optional
|
||||||
|
"collaborative" checkbox. Unchecked → a regular local bucket, same as
|
||||||
|
before. Checked → reveals a member picker listing all other relay
|
||||||
|
tenants, and the submit path goes through `/api/collab-buckets` instead
|
||||||
|
of the core `/api/buckets`.
|
||||||
|
- **See other members' buckets** appear automatically in the bucket bar.
|
||||||
|
Every 30 seconds the plugin polls the relay for a full resync; any new
|
||||||
|
or updated bucket the current user is a member of is mirrored into the
|
||||||
|
main `buckets` table, so the core bar renders it alongside everything
|
||||||
|
else.
|
||||||
|
- **Collab tabs are visually distinguished**: the folder icon is swapped
|
||||||
|
for `ph-users-three`, there's a subtle left-edge accent stripe, and the
|
||||||
|
right-click menu's rename / color / delete actions route through the
|
||||||
|
plugin endpoints so changes reach the relay and other members.
|
||||||
|
- **Unread state**: brand-new buckets and ones whose `updated_at` exceeds
|
||||||
|
the local `last_seen_at` show a red dot top-right of the tab and pulse 3
|
||||||
|
times on the next render. Clicking the tab triggers `mark-seen` which
|
||||||
|
clears the dot/pulse immediately and persists to the plugin backend.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two places store bucket state locally:
|
||||||
|
|
||||||
|
1. **Main `buckets` table** (core) — gains a `collab_uid` column so collab
|
||||||
|
buckets are indistinguishable from local buckets in the bucket bar,
|
||||||
|
bucket selects, card-to-bucket joins, etc. `collab_uid` is unique and
|
||||||
|
NULL for regular local buckets.
|
||||||
|
2. **Plugin `collab_buckets` table** — holds collab-only metadata
|
||||||
|
(`members_json`, `owner_username`, `updated_at`, `last_seen_at`) that
|
||||||
|
the main table doesn't need. On every poll the plugin keeps both
|
||||||
|
tables in sync.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───── Browser ─────┐ ┌──── Relay ────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ bucket-bar (core) │ │ │
|
||||||
|
│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │
|
||||||
|
│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │
|
||||||
|
│ │ collab_uid │ ───────────────────────────▶ │ │
|
||||||
|
│ │ │ │ collab_ │
|
||||||
|
│ collab-buckets.js │ POST /api/collab-buckets │ buckets │
|
||||||
|
│ ▲ observer │ /poll (every 30s) │ │
|
||||||
|
│ │ MutationObs │ ◀─────────────────────────── │ collab_ │
|
||||||
|
│ │ + delegation │ full resync │ bucket_ │
|
||||||
|
│ ▼ │ │ members │
|
||||||
|
│ backend.php │ │ │
|
||||||
|
│ ├─ collab_buckets│ │ │
|
||||||
|
│ │ plugin table │ │ │
|
||||||
|
│ └─ upserts/ │ │ │
|
||||||
|
│ deletes rows │ │ │
|
||||||
|
│ in main │ │ │
|
||||||
|
│ buckets table │ │ │
|
||||||
|
└───────────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Relay is source of truth** for bucket metadata + membership. The two
|
||||||
|
local tables are mirrors that together add `last_seen_at` (unread state,
|
||||||
|
user-specific, never synced).
|
||||||
|
- **Full resync every 30 s.** Locally cached buckets that are no longer
|
||||||
|
returned by the relay for this user = we were kicked (or the bucket was
|
||||||
|
deleted); both tables drop them and any cards referencing them fall back
|
||||||
|
to unsorted via `ON DELETE SET NULL`.
|
||||||
|
- **LWW per bucket**: every patch bumps `updated_at` on the relay. The
|
||||||
|
local poll only re-saves rows whose `updated_at` actually changed,
|
||||||
|
otherwise it's a no-op and `last_seen_at` is untouched so unread state
|
||||||
|
survives unrelated polls.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `plugin.json` | Plugin manifest (auto-enabled unless listed in `data/plugins.json`) |
|
||||||
|
| `backend.php` | Plugin backend: `collab_buckets` plugin table, relay proxy, full-resync poll, mark-seen, + `collabUpsertMainBucket` / `collabDeleteMainBucket` helpers that keep the core `buckets` table in sync |
|
||||||
|
| `collab-buckets.js` | Frontend: polling loop, exposes `window._collabBucketsCreateDialog` for the core `+ Bucket` button, MutationObserver on `#bucket-bar` to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for `collab_uid` buckets |
|
||||||
|
| `collab-buckets.css` | Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling |
|
||||||
|
| `README.md` | This file |
|
||||||
|
| `TODO.md` | Deferred features + Phase 3 roadmap |
|
||||||
|
|
||||||
|
### Core-side touchpoints (not in this directory)
|
||||||
|
|
||||||
|
| File | What changes |
|
||||||
|
|---|---|
|
||||||
|
| `src/Database.php` | Migration adds `buckets.collab_uid TEXT` + a unique partial index |
|
||||||
|
| `public/index.php` | `api/buckets` GET returns `collab_uid` (via `SELECT *`). `PATCH` only allows `position` on collab buckets (name/color must go through the plugin so other members see them). `DELETE` refuses collab buckets with a 400 pointing to `/api/collab-buckets/{uid}` |
|
||||||
|
| `public/js/app.js` | Exposes `window.state = state` so the plugin's observer can read `state.buckets` to resolve `collab_uid` per tab |
|
||||||
|
| `public/js/modules/buckets.js` | `+ Bucket` click checks `window._collabBucketsCreateDialog` before falling back to the simple dialog. Rename / color / delete actions check `bucket.collab_uid` and route through `window._collabBucketsPatch` / `_collabBucketsDelete` when set |
|
||||||
|
|
||||||
|
Relay-side files (not in this directory, part of the main relay deployment):
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `relay/inc/bootstrap.php` | Adds `collab_buckets` + `collab_bucket_members` tables (auto-migrated on first boot) |
|
||||||
|
| `relay/inc/routes/collab-buckets.php` | `POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]` |
|
||||||
|
| `relay/index.php` | `require`s the new route file after `chat.php` |
|
||||||
|
|
||||||
|
## API (local)
|
||||||
|
|
||||||
|
All routes live under `/api/collab-buckets` and are registered via
|
||||||
|
`PluginManager::instance()->onApiRoute()`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/collab-buckets` | List local mirror. Each row has `has_unread` computed from `last_seen_at` vs `updated_at`. |
|
||||||
|
| `POST` | `/api/collab-buckets` | `{name, color, members[]}` — generates UID, pushes to relay, mirrors local. The creator's own `last_seen_at = now` so the tile isn't unread for them. |
|
||||||
|
| `PATCH` | `/api/collab-buckets/{uid}` | `{name?, color?, members?}` — owner-only, proxied to relay. Local row updates on next poll. |
|
||||||
|
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → delete; member → leave. Relay decides based on the authenticated caller. |
|
||||||
|
| `POST` | `/api/collab-buckets/poll` | Full resync. Drops stale local rows, upserts changed remote rows, preserves `last_seen_at`. |
|
||||||
|
| `POST` | `/api/collab-buckets/mark-seen` | `{uid}` — sets `last_seen_at = now` so the dot / pulse clear. |
|
||||||
|
| `GET` | `/api/collab-buckets/relay-tenants` | Proxy to relay `/api/tenants` for the member-picker. |
|
||||||
|
|
||||||
|
## API (relay)
|
||||||
|
|
||||||
|
Exact endpoints the local plugin forwards to. Authentication: the standard
|
||||||
|
`X-Relay-User` / `X-Relay-Key` client-API headers that the existing chat and
|
||||||
|
messages routes also use.
|
||||||
|
|
||||||
|
### Buckets
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/collab-buckets` | `{uid, name, color, members: [usernames]}`. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. |
|
||||||
|
| `GET` | `/api/collab-buckets` | Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as `members: [usernames]`. |
|
||||||
|
| `PATCH` | `/api/collab-buckets/{uid}` | Owner-only. Member-list replacement is total — pass the full desired list. |
|
||||||
|
| `DELETE` | `/api/collab-buckets/{uid}` | Owner → `DELETE` of bucket + memberships (with `action: deleted`). Member → just that one membership row (with `action: left`); the bucket's `updated_at` is bumped so the owner sees the change on next poll. |
|
||||||
|
|
||||||
|
### Cards (Phase 3)
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/collab-cards` | `{bucket_uid, card_uid, payload}` — upsert. `payload` is a `HYPRCRD-RELAY-V1` envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via `masterDecrypt` of their stored `secret_encrypted`), re-encrypts with the inter-tenant key, stores. Server generates `updated_at` so all clients agree on the authoritative LWW timestamp. Membership-guarded. |
|
||||||
|
| `GET` | `/api/collab-cards?since=<ts>` | Pull cards updated since `<ts>` (ISO string from a previous response's `max_updated_at`) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with `deleted: true` and no payload. Hard-capped to 1000 cards per response. |
|
||||||
|
| `DELETE` | `/api/collab-cards/{bucket_uid}/{card_uid}` | Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. |
|
||||||
|
|
||||||
|
## Schema (local)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#38bdf8',
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
members_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TEXT -- NULL = never opened → unread
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: per-card sync state. Attached to the core `cards` table via
|
||||||
|
-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
|
||||||
|
-- relay; the push phase picks up cards where c.updated_at > last_push_at.
|
||||||
|
-- `last_remote_at` mirrors the updated_at we last applied from the relay.
|
||||||
|
CREATE TABLE collab_card_state (
|
||||||
|
card_id INTEGER PRIMARY KEY,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
last_push_at TEXT,
|
||||||
|
last_remote_at TEXT,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
|
||||||
|
-- cards table; drained in the push phase of the next poll.
|
||||||
|
CREATE TABLE collab_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`members_json` is a JSON array of usernames — flat and cheap to render.
|
||||||
|
Because the relay is the source of truth, we don't need to normalise
|
||||||
|
members into a separate local table.
|
||||||
|
|
||||||
|
The plugin also creates a BEFORE-DELETE trigger on `cards` that inserts a
|
||||||
|
tombstone row whenever a card in a collab bucket is deleted — unless the
|
||||||
|
suppress temp table `collab_suppress_outbox` has a row, which the plugin
|
||||||
|
sets during the poll's apply phase so remotely-triggered deletes don't
|
||||||
|
re-enqueue.
|
||||||
|
|
||||||
|
## Schema (relay)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collab_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE NOT NULL,
|
||||||
|
owner_id INTEGER NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#38bdf8',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE collab_bucket_members (
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (bucket_uid, tenant_id),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3: the actual synced card content. `payload` is the re-encrypted
|
||||||
|
-- envelope stored at rest under the inter-tenant key. `deleted = 1`
|
||||||
|
-- marks a tombstone; bootstrap.php prunes them after 7 days.
|
||||||
|
CREATE TABLE collab_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
author_username TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (bucket_uid, card_uid)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner deletion cascades via `ON DELETE CASCADE` on the `owner_id` FK.
|
||||||
|
Member rows are pruned explicitly on leave (or cascade when a tenant is
|
||||||
|
deleted outright). `collab_cards` rows belonging to a deleted bucket are
|
||||||
|
orphaned but unreachable — the GET query joins `collab_bucket_members`
|
||||||
|
and returns nothing for them, so they're effectively gone. Future hardening
|
||||||
|
could add a cascade trigger.
|
||||||
|
|
||||||
|
## Card sync flow (Phase 3)
|
||||||
|
|
||||||
|
Every poll does three things in strict order inside one request:
|
||||||
|
|
||||||
|
1. **Drain tombstones.** The `collab_tombstones` outbox table is populated
|
||||||
|
by a BEFORE DELETE trigger on `cards` — whenever a user deletes a card
|
||||||
|
that sits in a collab bucket, the trigger inserts a row `{relay_uid,
|
||||||
|
bucket_uid}` into the outbox. The poll pushes each row to
|
||||||
|
`DELETE /api/collab-cards/{bucket}/{card}` and drops the outbox entry
|
||||||
|
on success. Failed pushes stay queued and retry next poll.
|
||||||
|
|
||||||
|
2. **Push dirty cards.** One query finds every card in a collab bucket
|
||||||
|
whose `updated_at` is greater than `collab_card_state.last_push_at`
|
||||||
|
(or never-pushed). Each is encrypted with the local `relay_key` and
|
||||||
|
POSTed to `/api/collab-cards`. On success, `last_push_at` is stamped
|
||||||
|
with the server's returned `updated_at` so the next poll skips it.
|
||||||
|
Cards without a `relay_uid` get one assigned (16 hex chars) and the
|
||||||
|
core `cards.relay_uid` column is updated so the card has a stable
|
||||||
|
cross-tenant identity.
|
||||||
|
|
||||||
|
3. **Pull deltas.** `GET /api/collab-cards?since=<stored>` fetches every
|
||||||
|
card updated or tombstoned since the stored marker (core `settings`
|
||||||
|
row `collab_cards_last_remote_at`). For each incoming card:
|
||||||
|
- If `deleted: true` → look up the local card by `relay_uid` and
|
||||||
|
`DELETE` it. The BEFORE DELETE trigger is suppressed via the
|
||||||
|
`collab_suppress_outbox` temp-table flag so it doesn't re-enqueue
|
||||||
|
the tombstone back into the outbox.
|
||||||
|
- Otherwise, decrypt the payload, find the local card by `relay_uid`,
|
||||||
|
and apply LWW (`remote.updated_at > local.updated_at` → overwrite).
|
||||||
|
New cards are INSERTed with `bucket_id` resolved via
|
||||||
|
`buckets.collab_uid`. In both cases `collab_card_state.last_push_at`
|
||||||
|
and `last_remote_at` are set to the remote's `updated_at` so the
|
||||||
|
next push phase doesn't re-send what was just received.
|
||||||
|
|
||||||
|
Conflict resolution is strict LWW on `updated_at`. If two users save the
|
||||||
|
same card within the same second, the second writer wins; the first
|
||||||
|
writer's changes are silently overwritten on their next pull. Phase 4
|
||||||
|
adds optional edit-locks to surface the conflict before it happens.
|
||||||
|
|
||||||
|
## Unread / pulse state
|
||||||
|
|
||||||
|
`has_unread` is computed by the plugin backend on every read of
|
||||||
|
`/api/collab-buckets`:
|
||||||
|
|
||||||
|
```
|
||||||
|
has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Brand-new bucket** → poll inserts the plugin row with `last_seen_at =
|
||||||
|
NULL` → unread.
|
||||||
|
- **Metadata / member change** → relay bumps `updated_at` → poll upserts →
|
||||||
|
`updated_at > last_seen_at` → unread.
|
||||||
|
- **Click tab** → `mark-seen` sets `last_seen_at = now` → unread clears on
|
||||||
|
the next render.
|
||||||
|
|
||||||
|
The plugin frontend watches `#bucket-bar` via `MutationObserver`. After
|
||||||
|
every core re-render, it walks the tabs, looks up each bucket in
|
||||||
|
`state.buckets`, and applies `.bucket-tab-collab` plus (if unread) the
|
||||||
|
`.pulsing` class and an injected `.collab-tab-dot` element. The pulse
|
||||||
|
animation is CSS-driven (`collab-bucket-pulse 1.2s ease-in-out 3`), and
|
||||||
|
the JS strips `.pulsing` after 3.7 s so subsequent re-renders don't
|
||||||
|
retrigger it mid-cycle.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
All chrome hooks into the standard design tokens:
|
||||||
|
|
||||||
|
- `--ui-radius-button` — tile and button rounding
|
||||||
|
- `--ui-radius-modal` — create/edit modal
|
||||||
|
- `--ui-radius-input` — form inputs
|
||||||
|
- `--ui-shadow-modal` — modal drop shadow
|
||||||
|
- `--ui-blur-overlay` — modal backdrop blur
|
||||||
|
- `--collab-tile-color` (per-tile) — bucket color, mixed into border, text,
|
||||||
|
hover background, and pulse ring
|
||||||
|
|
||||||
|
So a collab tile automatically inherits the currently selected theme and the
|
||||||
|
user's design-token choices without any plugin-side theming code.
|
||||||
|
|
||||||
|
## Why this shape
|
||||||
|
|
||||||
|
- **First-class bucket rows, not a separate UI.** Collab buckets show up
|
||||||
|
exactly where users already look for buckets. No extra header tiles, no
|
||||||
|
second "collab+" button — the existing `+ Bucket` workflow just gains a
|
||||||
|
"collaborative" checkbox. The mental model stays "this is a bucket", the
|
||||||
|
plugin only layers sync on top.
|
||||||
|
- **Polling, not push.** No long-lived connection, no WebSocket, no daemon.
|
||||||
|
30-second cadence is perfectly fine for the "someone added a card to our
|
||||||
|
shared bucket" latency envelope and reuses the existing relay HTTP path.
|
||||||
|
- **Relay as source of truth.** The local mirror is almost stateless — only
|
||||||
|
`last_seen_at` is user-specific and never leaves the device. Wipes and
|
||||||
|
re-syncs are trivial.
|
||||||
|
- **Owner-only writes.** Simpler than ACLs. Owners who want to hand off
|
||||||
|
control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
|
||||||
|
- **Soft coupling to core.** Core exposes two tiny hooks
|
||||||
|
(`window._collabBucketsCreateDialog`, `window.state`) plus a single
|
||||||
|
column on the buckets table. Everything else — dialog, decoration,
|
||||||
|
polling, mark-seen — lives in the plugin directory and goes away when
|
||||||
|
the plugin is disabled.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Like every other plugin, add `"collab-buckets"` to the `disabled` array in
|
||||||
|
`data/plugins.json`. The local table is left in place (not dropped) so
|
||||||
|
re-enabling doesn't lose unread state.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# collab-buckets plugin — roadmap
|
||||||
|
|
||||||
|
> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar
|
||||||
|
> integration, and full card sync with encrypted round-trip and LWW
|
||||||
|
> conflict resolution. Phase 4 is polish — edit locks, ownership
|
||||||
|
> transfer, member presence, etc. Phase 5 is hardening.
|
||||||
|
|
||||||
|
## Phase 3 — card sync (DONE ✓)
|
||||||
|
|
||||||
|
- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to
|
||||||
|
a collab `bucket_uid` + `relay_uid` plus `last_push_at` and
|
||||||
|
`last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards
|
||||||
|
via `c.updated_at > cs.last_push_at`.
|
||||||
|
- [x] **Save propagation without editor coupling** — the poll's push
|
||||||
|
phase is query-based (no trigger, no hook). Whenever the user
|
||||||
|
saves a card and the core bumps `cards.updated_at`, the next
|
||||||
|
poll within 30s finds it dirty and pushes.
|
||||||
|
- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards`
|
||||||
|
decrypts-reencrypts via inter-tenant key; `GET ?since=<ts>`
|
||||||
|
re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a
|
||||||
|
tombstone with a 7-day TTL (cleaned up by bootstrap.php).
|
||||||
|
- [x] **Poll extension** — `collabSyncCards()` runs three phases
|
||||||
|
(tombstones → push → pull) with a temp-table suppress flag so the
|
||||||
|
delete trigger doesn't re-enqueue during apply.
|
||||||
|
- [x] **Card visibility** — collab buckets already take part in the core
|
||||||
|
bucket-bar's click-to-filter flow, so cards synced into them
|
||||||
|
automatically show up on the board.
|
||||||
|
- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to
|
||||||
|
`collab_tombstones`. Drained in the next poll as a DELETE.
|
||||||
|
Suppressed during the apply phase so remote deletes don't loop
|
||||||
|
back as local tombstones.
|
||||||
|
- [x] **Conflict losers as "(conflict from X)" duplicates** — when the
|
||||||
|
apply phase is about to LWW-clobber a local card that had edits
|
||||||
|
since our last push (`local.updated_at > collab_card_state.last_push_at`),
|
||||||
|
we now INSERT a copy of the local version into a local-only
|
||||||
|
"Conflicts" bucket with a `(conflict from <author>) ...` title
|
||||||
|
prefix before the overwrite. The poll response carries a
|
||||||
|
`conflicts` count; the frontend pops a toast pointing the user at
|
||||||
|
the Conflicts bucket when it's non-zero.
|
||||||
|
- [x] **Moving a card between collab buckets (or out to a local one)**
|
||||||
|
— the push phase now has a pre-pass that finds cards where
|
||||||
|
`collab_card_state.bucket_uid` no longer matches the card's
|
||||||
|
current bucket collab_uid. For each such card it sends a
|
||||||
|
tombstone to the old bucket. If the card is still in a (different)
|
||||||
|
collab bucket, state is rewritten with the new `bucket_uid` and
|
||||||
|
`last_push_at = NULL` so the main push loop re-uploads under the
|
||||||
|
new bucket. If the card left collab entirely, the state row is
|
||||||
|
removed.
|
||||||
|
|
||||||
|
## Phase 4 — polish
|
||||||
|
|
||||||
|
- [x] **Per-card edit lock** — when the editor opens on a collab card the
|
||||||
|
plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}`
|
||||||
|
every 30 s with a 60 s TTL on the relay. The lock list is folded
|
||||||
|
into the regular `/api/collab-buckets/poll` response so no extra
|
||||||
|
round-trips are needed. Foreign locks show a 🔒 badge on the
|
||||||
|
affected board card + a subtle inset outline. Releases on editor
|
||||||
|
close and best-effort on `beforeunload` via `fetch(..., keepalive)`.
|
||||||
|
Advisory only — users can still force-edit; LWW + conflict stashing
|
||||||
|
remain the final arbiter. Core `editor.js` dispatches
|
||||||
|
`hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so
|
||||||
|
the plugin hooks in without monkey-patching.
|
||||||
|
- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}`
|
||||||
|
accepts `{owner: <username>}`. The relay validates that the new
|
||||||
|
owner is currently a member, swaps `owner_id` + `owner_username`,
|
||||||
|
and forces both the caller and the new owner to stay in the member
|
||||||
|
list on the same request. Surfaced in the UI via a new "Members &
|
||||||
|
owner…" entry in the bucket context menu that opens a plugin
|
||||||
|
dialog with a member picker + owner dropdown (only shown when the
|
||||||
|
caller is the current owner; other members see a read-only view).
|
||||||
|
- [ ] **Position propagation (optional)** — reorder is purely local right
|
||||||
|
now. If multiple members expect the same order, we'd need to sync
|
||||||
|
`position` via the relay, but it's probably fine to leave this as a
|
||||||
|
per-user preference.
|
||||||
|
- [ ] **Member presence indicator** — show a small dot next to usernames in
|
||||||
|
the members panel when they were seen by the relay within the last
|
||||||
|
minute (reuse chat's heartbeat or add a collab-specific ping).
|
||||||
|
- [ ] **"Recently active members"** — show avatars (first letter) of
|
||||||
|
members who touched a card in the last hour, on the collab bucket tab.
|
||||||
|
- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each
|
||||||
|
collab bucket. Requires the relay to store per-member `last_seen_at`
|
||||||
|
— bigger schema change.
|
||||||
|
|
||||||
|
## Phase 5 — hardening & UX
|
||||||
|
|
||||||
|
- [x] **Migration of existing local buckets** — the bucket context menu
|
||||||
|
on a non-collab bucket shows a "Make collaborative…" entry when
|
||||||
|
the plugin is loaded. It opens a plugin dialog where the owner
|
||||||
|
picks members, then `POST /api/collab-buckets/upgrade
|
||||||
|
{bucket_id, members}` generates a new `uid`, pushes it to the
|
||||||
|
relay, and writes `collab_uid` onto the same local `buckets` row
|
||||||
|
— no card shuffling, no id rewrite. The first poll after the
|
||||||
|
upgrade sees every card in the bucket as dirty (no
|
||||||
|
`collab_card_state`) and pushes them.
|
||||||
|
- [x] **Undo for destructive actions** — local bucket delete/leave now
|
||||||
|
hooks into the existing `pushUndo` system: the undo entry
|
||||||
|
recreates the bucket via `POST /api/buckets`, restores the
|
||||||
|
original color+position with PATCH, and re-attaches every card
|
||||||
|
that pointed at the old row. Collab bucket delete/leave is
|
||||||
|
intentionally *not* wired — restoring would need to recreate on
|
||||||
|
the relay under a new uid and re-attach cards there too, which
|
||||||
|
is messy and less valuable since collab deletes are usually
|
||||||
|
deliberate.
|
||||||
|
- [x] **Resilient create via outbox** — a new
|
||||||
|
`collab_bucket_outbox(id, bucket_id, members_json, queued_at)`
|
||||||
|
table. The create/upgrade flow always creates the local `buckets`
|
||||||
|
row first and then tries `collabAttemptUpgrade()`. On relay
|
||||||
|
failure the intent is queued in the outbox and the next poll
|
||||||
|
retries. The response includes `queued: true` so the frontend can
|
||||||
|
show a "saved locally, will sync later" info toast; a follow-up
|
||||||
|
poll that successfully drains the outbox fires a second toast.
|
||||||
|
Stale outbox entries for deleted buckets are dropped automatically.
|
||||||
|
- [x] **Rate limiting on collab endpoints** — every collab route now
|
||||||
|
funnels through the existing relay `isRateLimited()` helper with
|
||||||
|
generous caps (120/min for GET, 240/min for POST). Over-limit
|
||||||
|
requests get a clean `429 rate limited` response.
|
||||||
|
- [ ] **Inline member picker in the bucket context menu** — currently
|
||||||
|
needs the manage-modal for any change. A quick `+user` / `-user`
|
||||||
|
flyout would help for tiny edits.
|
||||||
|
- [ ] **Owner-side member approval flow** — owners can still add any
|
||||||
|
relay user unilaterally. A pending-invite state with accept/reject
|
||||||
|
on the invitee side would be nicer for consent but requires a new
|
||||||
|
relay table + plumbing.
|
||||||
|
|
||||||
|
## Known limitations (current)
|
||||||
|
|
||||||
|
- **LWW conflicts still clobber on the remote side.** When two users save
|
||||||
|
the same card within a few seconds, the later writer wins. The earlier
|
||||||
|
writer gets their local version stashed into a `Conflicts` bucket and
|
||||||
|
a toast notification — nothing is lost — but their version is no
|
||||||
|
longer the canonical one anyone else sees. Phase 4 edit-locks prevent
|
||||||
|
the race in the first place.
|
||||||
|
- **Up to ~30 s sync latency.** Changes propagate on the next poll, not
|
||||||
|
instantly. Acceptable for MVP; a "push now" hook after save would cut
|
||||||
|
latency to ~1 s.
|
||||||
|
- **Full resync on every poll.** Cheap for small member counts but grows
|
||||||
|
O(N_buckets × N_members) on the relay side. A `since=updated_at` filter
|
||||||
|
would help — adding it is safe because the poll also has to handle full
|
||||||
|
removals (kicked from bucket), and we can fold the deletion list into the
|
||||||
|
response.
|
||||||
|
- **Bucket metadata is plaintext on the relay.** Name + color + member list
|
||||||
|
are visible to a relay admin with DB access. Card content (Phase 3) will
|
||||||
|
be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket
|
||||||
|
metadata could follow the same pattern but adds complexity for minimal
|
||||||
|
gain and makes member picking harder.
|
||||||
|
- **No offline support.** Creating a bucket while the relay is unreachable
|
||||||
|
fails hard. Phase 5 outbox pattern would fix this.
|
||||||
|
- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))`
|
||||||
|
= 64 bits of entropy. Collisions between independent clients are
|
||||||
|
astronomically unlikely, but the relay rejects them with 409 if they ever
|
||||||
|
happen. No retry on the client side yet.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,379 @@
|
|||||||
|
/* ── Collab buckets plugin ── */
|
||||||
|
|
||||||
|
/* Decoration for bucket-bar tabs that represent a collab bucket. The core
|
||||||
|
still renders the tab via its normal `.bucket-tab` rule; the plugin only
|
||||||
|
layers a few extras on top: a small 👥 icon swap (done in JS), an unread
|
||||||
|
dot pinned top-right, and a 3-cycle pulse animation when a fresh update
|
||||||
|
arrives. */
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab .collab-tab-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
border: 1px solid var(--t-bg);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-tab.bucket-tab-collab.pulsing {
|
||||||
|
animation: collab-bucket-pulse 1.2s ease-in-out 3;
|
||||||
|
}
|
||||||
|
/* Keep the 3-cycle cadence regardless of global animation-speed override */
|
||||||
|
body.anim-fast .bucket-tab.bucket-tab-collab.pulsing,
|
||||||
|
body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
|
||||||
|
animation: collab-bucket-pulse 1.2s ease-in-out 3 !important;
|
||||||
|
animation-duration: 1.2s !important;
|
||||||
|
}
|
||||||
|
@keyframes collab-bucket-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-danger) 60%, transparent); }
|
||||||
|
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--t-danger) 0%, transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Create / edit modal (used by the + Bucket button when the plugin
|
||||||
|
is loaded) ── */
|
||||||
|
.collab-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 240;
|
||||||
|
background: var(--t-overlay, rgba(0,0,0,0.6));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
|
||||||
|
}
|
||||||
|
.collab-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.collab-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.collab-modal-close:hover { color: var(--t-danger); border-color: var(--t-danger); }
|
||||||
|
.collab-modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.collab-field { display: block; margin-bottom: 14px; }
|
||||||
|
.collab-field:last-child { margin-bottom: 0; }
|
||||||
|
.collab-field.hidden { display: none; }
|
||||||
|
.collab-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.collab-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.collab-input:focus { border-color: var(--t-accent); }
|
||||||
|
.collab-color {
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-toggle-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.collab-toggle-row input { accent-color: var(--t-accent); cursor: pointer; }
|
||||||
|
.collab-toggle-row:hover { color: var(--t-bright); }
|
||||||
|
|
||||||
|
.collab-members-wrap { margin-top: 10px; }
|
||||||
|
.collab-members {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.collab-members-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.collab-member-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
border-radius: 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.collab-member-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.collab-member-row input { cursor: pointer; accent-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.collab-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-modal-status {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
.collab-btn-cancel,
|
||||||
|
.collab-btn-submit {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
color: var(--t-muted);
|
||||||
|
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
.collab-btn-cancel:hover {
|
||||||
|
color: var(--t-danger);
|
||||||
|
border-color: var(--t-danger);
|
||||||
|
}
|
||||||
|
.collab-btn-submit {
|
||||||
|
color: var(--t-accent);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.collab-btn-submit:hover {
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-readonly {
|
||||||
|
display: block;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collab-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phase 4: exclusive edit-lock indicator on board cards ──
|
||||||
|
Locked cards get a frosted overlay + centered lock icon. The card
|
||||||
|
content underneath stays visible enough to read but is clearly
|
||||||
|
"off-limits" — the cue is intentionally heavier than the old badge
|
||||||
|
so the user can't miss it. */
|
||||||
|
.board-card.collab-locked {
|
||||||
|
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 70%, transparent);
|
||||||
|
}
|
||||||
|
.board-card.collab-locked > *:not(.collab-lock-badge) {
|
||||||
|
filter: blur(2px);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: filter 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.collab-lock-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--t-accent2);
|
||||||
|
background: color-mix(in srgb, var(--t-surface) 60%, transparent);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
-webkit-backdrop-filter: blur(1px);
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.collab-lock-badge i {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.collab-lock-badge span {
|
||||||
|
color: var(--t-text);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor takeover (foreign lock arrived while editing) ── */
|
||||||
|
.collab-readonly-modal #inp-title,
|
||||||
|
.collab-readonly-modal #editor-body {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.collab-readonly-modal #editor-body {
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 6%, transparent) !important;
|
||||||
|
}
|
||||||
|
.collab-save-blocked {
|
||||||
|
opacity: 0.4 !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 12%, transparent);
|
||||||
|
border-bottom: 1px solid var(--t-danger);
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner i {
|
||||||
|
color: var(--t-danger);
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-takeover-banner b { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */
|
||||||
|
.collab-conflict-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 220;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px 10px 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-danger);
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
transition: opacity 0.4s;
|
||||||
|
}
|
||||||
|
.collab-conflict-toast i.ph-warning { color: var(--t-danger); flex-shrink: 0; }
|
||||||
|
.collab-conflict-toast b { color: var(--t-danger); font-weight: 700; }
|
||||||
|
.collab-conflict-toast.fade-out { opacity: 0; }
|
||||||
|
/* Queued variant reuses the layout but switches the danger accents for
|
||||||
|
a neutral info color — used for "relay was down, bucket is queued" and
|
||||||
|
"queued bucket finally synced" messages. */
|
||||||
|
.collab-conflict-toast.collab-queued-toast {
|
||||||
|
border-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-queued-toast i.ph-cloud-arrow-up {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-conflict-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.collab-conflict-close:hover { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Lock toast: same shape as the conflict toast but accented with the
|
||||||
|
collab/lock color. Reused for both the editor-takeover notification
|
||||||
|
and the "your push was queued because someone else is editing" toast. */
|
||||||
|
.collab-conflict-toast.collab-lock-toast {
|
||||||
|
border-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-lock-toast i.ph-lock-simple-fill {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.collab-conflict-toast.collab-lock-toast b {
|
||||||
|
color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Per-card unread indicator ──
|
||||||
|
Mirror of the bucket-tab red dot: a card with a foreign edit we haven't
|
||||||
|
opened yet gets a small glowing dot pinned to the top-right corner. */
|
||||||
|
.board-card.collab-card-unread { position: relative; }
|
||||||
|
.board-card.collab-card-unread .collab-card-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--t-danger);
|
||||||
|
border: 1px solid var(--t-bg);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "collab-buckets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Collaborative buckets shared between relay tenants. Polls the relay for bucket updates and shows pulsing header tiles with unread indicators.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["collab-buckets.js"],
|
||||||
|
"css": ["collab-buckets.css"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal pure-PHP IMAP4rev1 client.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - SSL/TLS connections (imaps://, port 993)
|
||||||
|
* - LOGIN, SELECT, UID SEARCH, UID FETCH, LOGOUT
|
||||||
|
* - MIME message body extraction (text/plain preferred, HTML fallback)
|
||||||
|
* - Quoted-printable + base64 decoding
|
||||||
|
* - MIME-encoded header decoding (=?utf-8?B?...?=)
|
||||||
|
*
|
||||||
|
* NOT supported (deferred for MVP): STARTTLS, IDLE, threads, attachments
|
||||||
|
* download, server-side search, multiple folders.
|
||||||
|
*
|
||||||
|
* Errors throw RuntimeException with a friendly message.
|
||||||
|
*/
|
||||||
|
class ImapClient
|
||||||
|
{
|
||||||
|
private $sock = null;
|
||||||
|
private int $tag = 0;
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $encryption; // 'ssl' | 'tls' | 'none'
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct(string $host, int $port = 993, string $encryption = 'ssl', int $timeout = 15)
|
||||||
|
{
|
||||||
|
$this->host = $host;
|
||||||
|
$this->port = $port;
|
||||||
|
$this->encryption = $encryption;
|
||||||
|
$this->timeout = $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function connect(): void
|
||||||
|
{
|
||||||
|
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
|
||||||
|
$url = $scheme . '://' . $this->host . ':' . $this->port;
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => true,
|
||||||
|
'verify_peer_name' => true,
|
||||||
|
'allow_self_signed' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$errno = 0; $errstr = '';
|
||||||
|
$this->sock = @stream_socket_client($url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||||
|
if (!$this->sock) {
|
||||||
|
throw new RuntimeException("Connect failed: $errstr ($errno)");
|
||||||
|
}
|
||||||
|
stream_set_timeout($this->sock, $this->timeout);
|
||||||
|
// Read greeting
|
||||||
|
$greet = $this->readLine();
|
||||||
|
if (strpos($greet, '* OK') !== 0 && strpos($greet, '* PREAUTH') !== 0) {
|
||||||
|
throw new RuntimeException('Bad server greeting: ' . trim($greet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $user, string $pass): void
|
||||||
|
{
|
||||||
|
// Quote string literals to be safe
|
||||||
|
$user = $this->quoteString($user);
|
||||||
|
$pass = $this->quoteString($pass);
|
||||||
|
$resp = $this->command("LOGIN $user $pass");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('LOGIN failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectFolder(string $folder = 'INBOX'): array
|
||||||
|
{
|
||||||
|
$folder = $this->quoteString($folder);
|
||||||
|
$resp = $this->command("SELECT $folder");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('SELECT failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
// Parse EXISTS count from untagged responses
|
||||||
|
$exists = 0;
|
||||||
|
foreach ($resp['untagged'] as $line) {
|
||||||
|
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
|
||||||
|
$exists = (int)$m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['exists' => $exists];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search returns UIDs of all messages matching criteria. */
|
||||||
|
public function uidSearch(string $criteria = 'ALL'): array
|
||||||
|
{
|
||||||
|
$resp = $this->command("UID SEARCH $criteria");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('SEARCH failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$uids = [];
|
||||||
|
foreach ($resp['untagged'] as $line) {
|
||||||
|
if (preg_match('/^\* SEARCH(.*)$/i', $line, $m)) {
|
||||||
|
preg_match_all('/\d+/', $m[1], $nums);
|
||||||
|
foreach ($nums[0] as $n) $uids[] = (int)$n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch envelope-like headers + spam-relevant fields for a list of UIDs.
|
||||||
|
* Returns [{uid, subject, from, date, seen, headers: <full-parsed-map>}, ...]
|
||||||
|
* The `headers` map contains lowercased keys ready for SpamDetector.
|
||||||
|
*/
|
||||||
|
public function fetchHeaders(array $uids): array
|
||||||
|
{
|
||||||
|
if (!$uids) return [];
|
||||||
|
sort($uids);
|
||||||
|
$set = $this->compactUidSet($uids);
|
||||||
|
// Pull more fields so SpamDetector has signals to work with
|
||||||
|
$fields = 'FROM SUBJECT DATE REPLY-TO MESSAGE-ID '
|
||||||
|
. 'X-SPAM-FLAG X-SPAM-STATUS X-SPAM-SCORE '
|
||||||
|
. 'AUTHENTICATION-RESULTS RECEIVED-SPF '
|
||||||
|
. 'LIST-UNSUBSCRIBE';
|
||||||
|
$resp = $this->command("UID FETCH $set (UID FLAGS BODY[HEADER.FIELDS ($fields)])");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('FETCH headers failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$messages = [];
|
||||||
|
foreach ($resp['fetches'] as $entry) {
|
||||||
|
$msg = [
|
||||||
|
'uid' => null,
|
||||||
|
'subject' => '',
|
||||||
|
'from' => '',
|
||||||
|
'date' => '',
|
||||||
|
'seen' => false,
|
||||||
|
'headers' => [],
|
||||||
|
];
|
||||||
|
if (preg_match('/UID (\d+)/', $entry['preamble'], $m)) $msg['uid'] = (int)$m[1];
|
||||||
|
if (preg_match('/FLAGS \(([^)]*)\)/', $entry['preamble'], $m)) {
|
||||||
|
$msg['seen'] = stripos($m[1], '\\Seen') !== false;
|
||||||
|
}
|
||||||
|
$headers = $this->parseHeaders($entry['body']);
|
||||||
|
// Decode MIME-encoded fields used by the UI
|
||||||
|
$msg['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||||
|
$msg['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||||
|
$msg['date'] = $headers['date'] ?? '';
|
||||||
|
// Pass the decoded versions back into the header map so SpamDetector
|
||||||
|
// sees the same strings the user sees
|
||||||
|
$headers['subject'] = $msg['subject'];
|
||||||
|
$headers['from'] = $msg['from'];
|
||||||
|
if (!empty($headers['reply-to'])) {
|
||||||
|
$headers['reply-to'] = $this->decodeMimeHeader($headers['reply-to']);
|
||||||
|
}
|
||||||
|
$msg['headers'] = $headers;
|
||||||
|
if ($msg['uid']) $messages[] = $msg;
|
||||||
|
}
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full message body and parse it.
|
||||||
|
* Returns ['headers' => [...], 'text' => '...', 'html' => '...', 'attachments' => [...]]
|
||||||
|
*/
|
||||||
|
public function fetchMessage(int $uid): array
|
||||||
|
{
|
||||||
|
$resp = $this->command("UID FETCH $uid (UID BODY.PEEK[])");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('FETCH body failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
if (!$resp['fetches']) {
|
||||||
|
throw new RuntimeException('Message not found');
|
||||||
|
}
|
||||||
|
$raw = $resp['fetches'][0]['body'];
|
||||||
|
return $this->parseMessage($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a list of UIDs as deleted (\Deleted flag) and expunge them.
|
||||||
|
* After expunge, those messages are gone from the server permanently.
|
||||||
|
* Returns the number of UIDs that were processed.
|
||||||
|
*/
|
||||||
|
public function deleteUids(array $uids): int
|
||||||
|
{
|
||||||
|
if (!$uids) return 0;
|
||||||
|
$set = $this->compactUidSet($uids);
|
||||||
|
$resp = $this->command("UID STORE $set +FLAGS (\\Deleted)");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('STORE \\Deleted failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$resp = $this->command('EXPUNGE');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('EXPUNGE failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
return count($uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
if ($this->sock) {
|
||||||
|
try { @$this->command('LOGOUT'); } catch (\Throwable $e) {}
|
||||||
|
@fclose($this->sock);
|
||||||
|
$this->sock = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
$this->logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Low-level protocol ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private function nextTag(): string
|
||||||
|
{
|
||||||
|
$this->tag++;
|
||||||
|
return 'A' . str_pad((string)$this->tag, 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeLine(string $line): void
|
||||||
|
{
|
||||||
|
if (fwrite($this->sock, $line . "\r\n") === false) {
|
||||||
|
throw new RuntimeException('Socket write failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readLine(): string
|
||||||
|
{
|
||||||
|
$line = fgets($this->sock);
|
||||||
|
if ($line === false) {
|
||||||
|
throw new RuntimeException('Socket read failed (timeout?)');
|
||||||
|
}
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readBytes(int $n): string
|
||||||
|
{
|
||||||
|
$buf = '';
|
||||||
|
while (strlen($buf) < $n) {
|
||||||
|
$chunk = fread($this->sock, $n - strlen($buf));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
throw new RuntimeException('Socket read truncated');
|
||||||
|
}
|
||||||
|
$buf .= $chunk;
|
||||||
|
}
|
||||||
|
return $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a command and read all response lines until tagged completion.
|
||||||
|
* Handles literal continuations ({N}\r\n followed by N bytes).
|
||||||
|
* Returns ['ok' => bool, 'text' => string, 'untagged' => [...], 'fetches' => [...]]
|
||||||
|
*/
|
||||||
|
private function command(string $cmd): array
|
||||||
|
{
|
||||||
|
$tag = $this->nextTag();
|
||||||
|
$this->writeLine("$tag $cmd");
|
||||||
|
|
||||||
|
$untagged = [];
|
||||||
|
$fetches = []; // [{preamble, body}]
|
||||||
|
$okResult = null;
|
||||||
|
$okText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$line = $this->readLine();
|
||||||
|
// Detect literal: {N}\r\n at end → read N more bytes, then continue
|
||||||
|
$literalBuf = '';
|
||||||
|
while (preg_match('/\{(\d+)\}\r?\n$/', $line, $m)) {
|
||||||
|
$bytes = (int)$m[1];
|
||||||
|
$literalBuf .= $this->readBytes($bytes);
|
||||||
|
$next = $this->readLine();
|
||||||
|
$line = rtrim($line, "\r\n") . "\x00LIT\x00" . $literalBuf . "\x00LITEND\x00" . $next;
|
||||||
|
$literalBuf = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagged response check
|
||||||
|
if (strpos($line, $tag . ' ') === 0) {
|
||||||
|
$rest = substr($line, strlen($tag) + 1);
|
||||||
|
$okResult = (strpos($rest, 'OK') === 0);
|
||||||
|
$okText = trim($rest);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untagged
|
||||||
|
if (strpos($line, '* ') === 0) {
|
||||||
|
$untagged[] = trim($line);
|
||||||
|
// Detect FETCH responses with literal body
|
||||||
|
if (preg_match('/^\* \d+ FETCH /', $line)) {
|
||||||
|
if (preg_match('/\x00LIT\x00(.*?)\x00LITEND\x00/s', $line, $m)) {
|
||||||
|
$body = $m[1];
|
||||||
|
$preamble = preg_replace('/\x00LIT\x00.*?\x00LITEND\x00.*$/s', '', $line);
|
||||||
|
$fetches[] = ['preamble' => $preamble, 'body' => $body];
|
||||||
|
} else {
|
||||||
|
// No literal, body might be inline (small messages)
|
||||||
|
$fetches[] = ['preamble' => $line, 'body' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => $okResult, 'text' => $okText, 'untagged' => $untagged, 'fetches' => $fetches];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteString(string $s): string
|
||||||
|
{
|
||||||
|
// Simple quoting; passwords with quotes are escaped
|
||||||
|
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $s) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compactUidSet(array $uids): string
|
||||||
|
{
|
||||||
|
// For simplicity, just send a comma list (most servers accept this).
|
||||||
|
// Could be optimized to ranges (1:5,10,15:20).
|
||||||
|
return implode(',', $uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MIME parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function parseHeaders(string $raw): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
$lines = preg_split('/\r?\n/', $raw);
|
||||||
|
$current = null;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($line === '') break;
|
||||||
|
if (preg_match('/^([!-9;-~]+):\s*(.*)$/', $line, $m)) {
|
||||||
|
$current = strtolower($m[1]);
|
||||||
|
$headers[$current] = $m[2];
|
||||||
|
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
|
||||||
|
// Continuation
|
||||||
|
$headers[$current] .= ' ' . trim($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decodeMimeHeader(string $value): string
|
||||||
|
{
|
||||||
|
// Decode =?charset?B?...?= or =?charset?Q?...?=
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
|
||||||
|
function ($m) {
|
||||||
|
$charset = strtoupper($m[1]);
|
||||||
|
$enc = strtoupper($m[2]);
|
||||||
|
$data = $m[3];
|
||||||
|
if ($enc === 'B') {
|
||||||
|
$decoded = base64_decode($data);
|
||||||
|
} else { // Q
|
||||||
|
$decoded = quoted_printable_decode(str_replace('_', ' ', $data));
|
||||||
|
}
|
||||||
|
if ($charset !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
},
|
||||||
|
$value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a full RFC 822 message into headers + plain/html bodies.
|
||||||
|
* Returns ['headers' => [...], 'text' => string, 'html' => string]
|
||||||
|
*/
|
||||||
|
public function parseMessage(string $raw): array
|
||||||
|
{
|
||||||
|
// Split header / body at first blank line
|
||||||
|
$pos = strpos($raw, "\r\n\r\n");
|
||||||
|
if ($pos === false) $pos = strpos($raw, "\n\n");
|
||||||
|
$headerRaw = $pos !== false ? substr($raw, 0, $pos) : $raw;
|
||||||
|
$bodyRaw = $pos !== false ? substr($raw, $pos + ($raw[$pos] === "\r" ? 4 : 2)) : '';
|
||||||
|
|
||||||
|
$headers = $this->parseHeaders($headerRaw);
|
||||||
|
$headers['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||||
|
$headers['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||||
|
$headers['to'] = $this->decodeMimeHeader($headers['to'] ?? '');
|
||||||
|
|
||||||
|
$contentType = $headers['content-type'] ?? 'text/plain';
|
||||||
|
$encoding = strtolower($headers['content-transfer-encoding'] ?? '7bit');
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
if (stripos($contentType, 'multipart/') === 0) {
|
||||||
|
$boundary = $this->extractBoundary($contentType);
|
||||||
|
if ($boundary) {
|
||||||
|
[$text, $html] = $this->parseMultipart($bodyRaw, $boundary);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$decoded = $this->decodeBody($bodyRaw, $encoding);
|
||||||
|
$charset = $this->extractCharset($contentType);
|
||||||
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
if (stripos($contentType, 'text/html') !== false) {
|
||||||
|
$html = $decoded;
|
||||||
|
} else {
|
||||||
|
$text = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['headers' => $headers, 'text' => $text, 'html' => $html];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseMultipart(string $body, string $boundary): array
|
||||||
|
{
|
||||||
|
$parts = explode('--' . $boundary, $body);
|
||||||
|
// First chunk before first boundary, last chunk has -- closing — discard
|
||||||
|
array_shift($parts);
|
||||||
|
array_pop($parts);
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = ltrim($part, "\r\n");
|
||||||
|
$pos = strpos($part, "\r\n\r\n");
|
||||||
|
if ($pos === false) $pos = strpos($part, "\n\n");
|
||||||
|
if ($pos === false) continue;
|
||||||
|
$partHeaderRaw = substr($part, 0, $pos);
|
||||||
|
$partBody = substr($part, $pos + ($part[$pos] === "\r" ? 4 : 2));
|
||||||
|
$partHeaders = $this->parseHeaders($partHeaderRaw);
|
||||||
|
$ct = $partHeaders['content-type'] ?? 'text/plain';
|
||||||
|
$cte = strtolower($partHeaders['content-transfer-encoding'] ?? '7bit');
|
||||||
|
|
||||||
|
if (stripos($ct, 'multipart/') === 0) {
|
||||||
|
$innerBoundary = $this->extractBoundary($ct);
|
||||||
|
if ($innerBoundary) {
|
||||||
|
[$nestedText, $nestedHtml] = $this->parseMultipart($partBody, $innerBoundary);
|
||||||
|
if ($nestedText && !$text) $text = $nestedText;
|
||||||
|
if ($nestedHtml && !$html) $html = $nestedHtml;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $this->decodeBody($partBody, $cte);
|
||||||
|
$charset = $this->extractCharset($ct);
|
||||||
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripos($ct, 'text/plain') !== false && !$text) {
|
||||||
|
$text = $decoded;
|
||||||
|
} elseif (stripos($ct, 'text/html') !== false && !$html) {
|
||||||
|
$html = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$text, $html];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBody(string $body, string $encoding): string
|
||||||
|
{
|
||||||
|
switch ($encoding) {
|
||||||
|
case 'base64':
|
||||||
|
return base64_decode(preg_replace('/\s+/', '', $body)) ?: '';
|
||||||
|
case 'quoted-printable':
|
||||||
|
return quoted_printable_decode($body);
|
||||||
|
default:
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractBoundary(string $contentType): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractCharset(string $contentType): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/charset\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpamDetector — multi-signal heuristic spam scoring.
|
||||||
|
*
|
||||||
|
* Composes signals from:
|
||||||
|
* - Upstream filter results (X-Spam-Flag, X-Spam-Status, X-Spam-Score)
|
||||||
|
* - Authentication-Results / Received-SPF (SPF/DKIM/DMARC pass/fail)
|
||||||
|
* - Subject heuristics (ALL CAPS, excess punctuation, spam keywords EN+DE)
|
||||||
|
* - Sender heuristics (suspicious TLDs, digit-heavy domains, lookalikes,
|
||||||
|
* display name mismatch, missing/mismatched Reply-To)
|
||||||
|
* - Optional body heuristics (link density, short URLs, currency spam)
|
||||||
|
*
|
||||||
|
* Scoring:
|
||||||
|
* 0–3 → clean (no marking)
|
||||||
|
* 4–7 → suspicious (yellow)
|
||||||
|
* 8+ → spam (red)
|
||||||
|
*/
|
||||||
|
class SpamDetector
|
||||||
|
{
|
||||||
|
/** User-defined block rules: sender emails (lowercase). */
|
||||||
|
private array $blockEmails = [];
|
||||||
|
/** User-defined block rules: sender domains (lowercase). */
|
||||||
|
private array $blockDomains = [];
|
||||||
|
/** User-defined allow rules. */
|
||||||
|
private array $allowEmails = [];
|
||||||
|
private array $allowDomains = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $rules Optional array of rule rows from email_spam_rules:
|
||||||
|
* [{rule_type, sender_email, sender_domain}, ...]
|
||||||
|
*/
|
||||||
|
public function __construct(array $rules = [])
|
||||||
|
{
|
||||||
|
foreach ($rules as $r) {
|
||||||
|
$type = $r['rule_type'] ?? '';
|
||||||
|
$email = strtolower(trim((string)($r['sender_email'] ?? '')));
|
||||||
|
$domain = strtolower(trim((string)($r['sender_domain'] ?? '')));
|
||||||
|
if ($type === 'block') {
|
||||||
|
if ($email) $this->blockEmails[] = $email;
|
||||||
|
if ($domain) $this->blockDomains[] = $domain;
|
||||||
|
} elseif ($type === 'allow') {
|
||||||
|
if ($email) $this->allowEmails[] = $email;
|
||||||
|
if ($domain) $this->allowDomains[] = $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English + German spam keywords. Each match adds 2 points (capped). */
|
||||||
|
private const SPAM_PHRASES = [
|
||||||
|
// English
|
||||||
|
'viagra', 'cialis', 'pharmacy', 'rolex', 'casino', 'lottery', 'winner',
|
||||||
|
'congratulations', 'claim your prize', 'act now', 'click here', 'free gift',
|
||||||
|
'limited time', 'risk free', 'no obligation', 'satisfaction guaranteed',
|
||||||
|
'money back', 'lose weight', 'work from home', 'make money fast',
|
||||||
|
'earn extra cash', 'inheritance', 'beneficiary', 'next of kin',
|
||||||
|
'crypto millionaire', '100% free', '100% satisfied', 'urgent reply',
|
||||||
|
'wire transfer', 'western union', 'nigerian prince', 'unclaimed funds',
|
||||||
|
'verify your account', 'suspended account', 'confirm your identity',
|
||||||
|
// German
|
||||||
|
'sie haben gewonnen', 'gewinnspiel', 'lotto', 'erbschaft', 'sofort gewinn',
|
||||||
|
'kostenlos', 'gratis', 'nur heute', 'einmalig', 'jetzt zugreifen',
|
||||||
|
'jetzt bestellen', 'risikofrei', 'geld verdienen', 'schnell reich',
|
||||||
|
'abnehmen ohne', 'wundermittel', 'klicken sie hier', 'bestätigen sie',
|
||||||
|
'ihr konto wurde', 'ihr passwort', 'verifizieren sie', 'dringend antworten',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Suspicious TLDs frequently used by spammers. */
|
||||||
|
private const SUSPICIOUS_TLDS = [
|
||||||
|
'top', 'xyz', 'click', 'link', 'work', 'gq', 'ml', 'tk', 'ga', 'cf',
|
||||||
|
'loan', 'racing', 'win', 'download', 'review', 'country', 'kim',
|
||||||
|
'science', 'party', 'bid', 'trade', 'date', 'stream', 'cricket',
|
||||||
|
'webcam', 'mom', 'rest',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** URL shorteners (high spam correlation in cold email). */
|
||||||
|
private const SHORT_URL_HOSTS = [
|
||||||
|
'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly',
|
||||||
|
'tiny.cc', 'cutt.ly', 'shorturl.at', 'rebrand.ly', 'rb.gy',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $headers Lowercase-keyed header map (as returned by ImapClient::parseHeaders)
|
||||||
|
* @param string|null $bodyText Optional plaintext body for content checks
|
||||||
|
* @return array{score:int, level:string, reasons:array<string>}
|
||||||
|
*/
|
||||||
|
public function score(array $headers, ?string $bodyText = null): array
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
// ── User-defined rules (override the heuristic with strong weight) ──
|
||||||
|
$fromAddrEarly = $this->extractAddress($headers['from'] ?? '');
|
||||||
|
$fromDomainEarly = strtolower($this->extractDomain($fromAddrEarly));
|
||||||
|
$fromAddrLow = strtolower($fromAddrEarly);
|
||||||
|
|
||||||
|
if ($fromAddrLow && in_array($fromAddrLow, $this->allowEmails, true)) {
|
||||||
|
$score -= 15;
|
||||||
|
$reasons[] = 'sender on allowlist (email)';
|
||||||
|
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->allowDomains, true)) {
|
||||||
|
$score -= 12;
|
||||||
|
$reasons[] = 'sender domain on allowlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fromAddrLow && in_array($fromAddrLow, $this->blockEmails, true)) {
|
||||||
|
$score += 15;
|
||||||
|
$reasons[] = 'sender on blocklist (email)';
|
||||||
|
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->blockDomains, true)) {
|
||||||
|
$score += 12;
|
||||||
|
$reasons[] = 'sender domain on blocklist';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upstream filter verdict (strongest signal) ──────────────
|
||||||
|
|
||||||
|
$xSpamFlag = strtolower(trim($headers['x-spam-flag'] ?? ''));
|
||||||
|
if ($xSpamFlag === 'yes') {
|
||||||
|
$score += 10;
|
||||||
|
$reasons[] = 'upstream X-Spam-Flag: YES';
|
||||||
|
}
|
||||||
|
|
||||||
|
$xSpamStatus = $headers['x-spam-status'] ?? '';
|
||||||
|
if (preg_match('/^Yes/i', $xSpamStatus)) {
|
||||||
|
$score += 8;
|
||||||
|
$reasons[] = 'upstream X-Spam-Status: Yes';
|
||||||
|
}
|
||||||
|
if (preg_match('/score\s*=\s*([\d.]+)/i', $xSpamStatus, $m)) {
|
||||||
|
$sa = (float)$m[1];
|
||||||
|
if ($sa >= 5) {
|
||||||
|
$score += 5;
|
||||||
|
$reasons[] = "SpamAssassin score $sa";
|
||||||
|
} elseif ($sa >= 3) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = "SpamAssassin score $sa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$xSpamScore = $headers['x-spam-score'] ?? '';
|
||||||
|
if (is_numeric(trim($xSpamScore)) && (float)$xSpamScore >= 5) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = "X-Spam-Score $xSpamScore";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentication results (SPF/DKIM/DMARC) ─────────────────
|
||||||
|
|
||||||
|
$authResults = strtolower($headers['authentication-results'] ?? '');
|
||||||
|
$receivedSpf = strtolower($headers['received-spf'] ?? '');
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'spf=fail') || str_starts_with($receivedSpf, 'fail')) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'SPF fail';
|
||||||
|
} elseif (str_contains($authResults, 'spf=softfail') || str_starts_with($receivedSpf, 'softfail')) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'SPF softfail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'dkim=fail')) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = 'DKIM fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'dmarc=fail')) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'DMARC fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing Message-ID is rare in legitimate mail
|
||||||
|
if (empty($headers['message-id']) && empty($headers['x-spam-flag'])) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'missing Message-ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subject heuristics ──────────────────────────────────────
|
||||||
|
|
||||||
|
$subject = $headers['subject'] ?? '';
|
||||||
|
if ($subject !== '') {
|
||||||
|
// ALL CAPS check (only count letters; ignore short subjects)
|
||||||
|
$letters = preg_replace('/[^A-Za-zÄÖÜäöüß]/u', '', $subject);
|
||||||
|
if (strlen($letters) >= 12) {
|
||||||
|
$upper = preg_replace('/[^A-ZÄÖÜ]/u', '', $letters);
|
||||||
|
$ratio = strlen($upper) / max(1, strlen($letters));
|
||||||
|
if ($ratio > 0.7) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = 'ALL CAPS subject';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excessive exclamation/question marks
|
||||||
|
$excessPunct = preg_match_all('/[!?]{2,}/', $subject);
|
||||||
|
if ($excessPunct >= 1) {
|
||||||
|
$score += min(3, $excessPunct + 1);
|
||||||
|
$reasons[] = 'excessive punctuation';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency clusters
|
||||||
|
if (preg_match_all('/[\$€£¥]\s*\d+|\d+\s*[\$€£¥]|EUR\s*\d+|USD\s*\d+/i', $subject, $cm)) {
|
||||||
|
if (count($cm[0]) >= 1) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'currency in subject';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword matches (capped at +6)
|
||||||
|
$kwScore = 0;
|
||||||
|
$kwHits = 0;
|
||||||
|
$lowSubject = function_exists('mb_strtolower') ? mb_strtolower($subject) : strtolower($subject);
|
||||||
|
foreach (self::SPAM_PHRASES as $phrase) {
|
||||||
|
if (str_contains($lowSubject, $phrase)) {
|
||||||
|
$kwScore += 2;
|
||||||
|
$kwHits++;
|
||||||
|
if ($kwHits >= 3) break; // cap at 3 distinct hits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($kwScore > 0) {
|
||||||
|
$score += min(6, $kwScore);
|
||||||
|
$reasons[] = "$kwHits spam keyword" . ($kwHits === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cyrillic homoglyph attack: Latin recipients getting Cyrillic letters
|
||||||
|
if (preg_match('/[\x{0400}-\x{04FF}]/u', $subject)
|
||||||
|
&& preg_match('/[a-zA-Z]/', $subject)) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'mixed Latin/Cyrillic (homoglyph)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sender heuristics ───────────────────────────────────────
|
||||||
|
|
||||||
|
$from = $headers['from'] ?? '';
|
||||||
|
$replyTo = $headers['reply-to'] ?? '';
|
||||||
|
|
||||||
|
$fromAddr = $this->extractAddress($from);
|
||||||
|
$replyAddr = $this->extractAddress($replyTo);
|
||||||
|
$fromDomain = $this->extractDomain($fromAddr);
|
||||||
|
$replyDomain = $this->extractDomain($replyAddr);
|
||||||
|
|
||||||
|
// Reply-To pointing to a different domain than From (classic phishing)
|
||||||
|
if ($replyDomain && $fromDomain && strcasecmp($replyDomain, $fromDomain) !== 0) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = "Reply-To domain mismatch ($replyDomain ≠ $fromDomain)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspicious TLD
|
||||||
|
if ($fromDomain) {
|
||||||
|
$tld = strtolower((string)substr(strrchr($fromDomain, '.'), 1));
|
||||||
|
if (in_array($tld, self::SUSPICIOUS_TLDS, true)) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = "suspicious TLD .$tld";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digit-heavy domain (often throwaways like x4892jk-shop.com)
|
||||||
|
$digitCount = preg_match_all('/\d/', $fromDomain);
|
||||||
|
$domainLen = strlen($fromDomain);
|
||||||
|
if ($domainLen > 8 && $digitCount / $domainLen > 0.3) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'digit-heavy sender domain';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyphen overload
|
||||||
|
if (substr_count($fromDomain, '-') >= 3) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'many hyphens in domain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name impersonates an email address that doesn't match the actual address
|
||||||
|
if (preg_match('/^"?([^"<]+)"?\s*<([^>]+)>$/', $from, $fm)) {
|
||||||
|
$displayName = trim($fm[1]);
|
||||||
|
$actualAddr = trim($fm[2]);
|
||||||
|
if (preg_match('/[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $displayName, $dm)) {
|
||||||
|
if (strcasecmp($dm[0], $actualAddr) !== 0) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'display name impersonates a different email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List-Unsubscribe present is a slight signal it's bulk
|
||||||
|
// (not necessarily spam, just a small positive bump)
|
||||||
|
if (!empty($headers['list-unsubscribe'])) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'bulk mail (list-unsubscribe)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body heuristics (only if body is provided) ──────────────
|
||||||
|
|
||||||
|
if ($bodyText) {
|
||||||
|
// Count URLs
|
||||||
|
preg_match_all('#https?://([^\s<>"\']+)#i', $bodyText, $urlMatches);
|
||||||
|
$urls = $urlMatches[1] ?? [];
|
||||||
|
$urlCount = count($urls);
|
||||||
|
if ($urlCount > 8) {
|
||||||
|
$score += min(4, intdiv($urlCount, 4));
|
||||||
|
$reasons[] = "$urlCount links in body";
|
||||||
|
}
|
||||||
|
|
||||||
|
$shortCount = 0;
|
||||||
|
foreach ($urls as $u) {
|
||||||
|
$host = strtolower(parse_url('http://' . $u, PHP_URL_HOST) ?? '');
|
||||||
|
if ($host && in_array($host, self::SHORT_URL_HOSTS, true)) $shortCount++;
|
||||||
|
}
|
||||||
|
if ($shortCount >= 2) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = "$shortCount short URLs";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Determine level ─────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($score >= 8) {
|
||||||
|
$level = 'spam';
|
||||||
|
} elseif ($score >= 4) {
|
||||||
|
$level = 'suspicious';
|
||||||
|
} else {
|
||||||
|
$level = 'clean';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['score' => $score, 'level' => $level, 'reasons' => $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractAddress(string $field): string
|
||||||
|
{
|
||||||
|
if (preg_match('/<([^>]+)>/', $field, $m)) return trim($m[1]);
|
||||||
|
if (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $field, $m)) return $m[0];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractDomain(string $address): string
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
return $at === false ? '' : strtolower(substr($address, $at + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# email plugin — deferred features
|
||||||
|
|
||||||
|
> **Note:** Account management lives in the main settings overlay under
|
||||||
|
> "mail" (added via `hyprcrd.settings.registerTab`). The email overlay
|
||||||
|
> shows a welcome screen with a "configure accounts" button when no
|
||||||
|
> accounts are set up — that button switches to the settings tab.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This is the MVP scope. The following are intentionally not implemented yet
|
||||||
|
and can be added later as plugin-only changes (no core touch needed):
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
- [ ] **SMTP / sending** — currently read-only. Add SmtpClient.php + a
|
||||||
|
compose view in the overlay. Reuse account credentials (most providers
|
||||||
|
use the same login for IMAP + SMTP, just different host/port).
|
||||||
|
- [ ] **STARTTLS on port 143** — only direct SSL on 993 works today. Need to
|
||||||
|
send `STLS` command after the greeting and wrap the socket via
|
||||||
|
`stream_socket_enable_crypto`.
|
||||||
|
- [ ] **IDLE / push** — currently each `loadMessages()` is a fresh poll. IDLE
|
||||||
|
would let the server notify on new mail. Tricky with PHP request model;
|
||||||
|
probably needs a small daemon or long-poll endpoint.
|
||||||
|
- [ ] **OAuth2 (XOAUTH2)** — Gmail/O365 require OAuth instead of app
|
||||||
|
passwords for full functionality. Big chunk of work: token exchange,
|
||||||
|
refresh, storage.
|
||||||
|
|
||||||
|
## Folders & search
|
||||||
|
|
||||||
|
- [ ] **Multiple folders** — schema has `folder` column but UI exposes only
|
||||||
|
one. Add folder list pane (like Thunderbird's left sidebar) using
|
||||||
|
`LIST "" *`.
|
||||||
|
- [ ] **Server-side search** — `UID SEARCH FROM "..."`, `SUBJECT "..."`,
|
||||||
|
`SINCE date`, etc. Currently the UI shows the latest 30 only.
|
||||||
|
- [ ] **Threading** — group messages by `In-Reply-To` / `References` headers.
|
||||||
|
- [ ] **Mark as read / unread** — `UID STORE +FLAGS (\Seen)`.
|
||||||
|
- [ ] **Move to folder** — `UID COPY <folder>` + delete from current.
|
||||||
|
- [x] **Delete on server** — implemented: multi-select checkboxes in list pane,
|
||||||
|
"delete" button in selection bar, calls `UID STORE +FLAGS (\Deleted)`
|
||||||
|
+ `EXPUNGE` (see `ImapClient::deleteUids()`).
|
||||||
|
|
||||||
|
## Body & attachments
|
||||||
|
|
||||||
|
- [x] **HTML email rendering** — implemented: opt-in "view as html" button per
|
||||||
|
message renders the HTML in a sandboxed iframe (srcdoc + DOMPurify).
|
||||||
|
External images are blocked by default; "load images (N)" button reveals
|
||||||
|
them on demand. CSS background-image url() declarations are also stripped
|
||||||
|
from inline styles. cid: refs aren't loaded (would need a separate route).
|
||||||
|
- [ ] **Inline images** — `cid:` references inside HTML need to fetch
|
||||||
|
`multipart/related` parts and serve them via a data: URL.
|
||||||
|
- [ ] **Attachments list + download** — parse `Content-Disposition: attachment`
|
||||||
|
parts, expose them in the message header bar, route through a backend
|
||||||
|
endpoint that streams the decoded part to the browser.
|
||||||
|
|
||||||
|
## UI / UX
|
||||||
|
|
||||||
|
- [ ] **Pagination** — currently hard limit of 30 newest messages.
|
||||||
|
- [ ] **Per-account folder + signature settings** — extend the form.
|
||||||
|
- [ ] **Notification on new mail** — needs IDLE or polling daemon.
|
||||||
|
- [ ] **Drag-extract** — drag a message onto the board to create a card at
|
||||||
|
that position.
|
||||||
|
- [ ] **Reply / forward** — needs SMTP first.
|
||||||
|
- [ ] **Address book / autocomplete** — pull historic sender addresses.
|
||||||
|
|
||||||
|
## Spam detection (current state)
|
||||||
|
|
||||||
|
- [x] **Multi-signal heuristic scorer** (`SpamDetector.php`):
|
||||||
|
header signals (X-Spam-*, SPF/DKIM/DMARC), subject heuristics
|
||||||
|
(CAPS, punctuation, EN+DE keyword phrases, Cyrillic homoglyphs),
|
||||||
|
sender heuristics (suspicious TLDs, digit-heavy domains, Reply-To
|
||||||
|
mismatch, display-name impersonation), bulk-mail signal.
|
||||||
|
Three levels: clean / suspicious / spam.
|
||||||
|
- [x] **Visual marking** in message list: red row + badge for spam,
|
||||||
|
yellow row + badge for suspicious. Hover tooltip shows reasons.
|
||||||
|
- [ ] **Body-content scoring** — `SpamDetector::score()` already accepts
|
||||||
|
a body parameter; wire it up in the message-open route to refine
|
||||||
|
the score with link density / short URL detection. Currently the
|
||||||
|
list view scores headers only.
|
||||||
|
- [x] **User feedback loop** — implemented: `email_spam_rules` table holds
|
||||||
|
block/allow rules per sender email or domain. UI has "select spam"
|
||||||
|
bulk-select button + "spam" / "not spam" buttons in the selection bar.
|
||||||
|
Free email providers (gmail, gmx, etc.) get email-only rules to avoid
|
||||||
|
blocking entire providers; other domains get domain-level rules.
|
||||||
|
Block adds +15, allow subtracts -15 in `SpamDetector`.
|
||||||
|
- [ ] **Bayesian classifier** — collect tokens from user-marked spam vs
|
||||||
|
ham; train a per-account model. SQLite-backed, no external deps.
|
||||||
|
- [ ] **Spam rules management UI** — currently rules are write-only via
|
||||||
|
mark buttons. A list view in settings would let users see and remove
|
||||||
|
stale rules.
|
||||||
|
|
||||||
|
## Hardening
|
||||||
|
|
||||||
|
- [ ] **Rate limiting** on the test endpoint to prevent credential brute force.
|
||||||
|
- [ ] **Better error messages** — currently raw IMAP server errors leak
|
||||||
|
through. Should map common ones (auth failed, connection refused,
|
||||||
|
cert error) to friendly text.
|
||||||
|
- [ ] **Cert pinning option** for users on self-hosted mail servers.
|
||||||
|
- [ ] **Connection reuse** — every API call opens + closes IMAP. A short
|
||||||
|
session cache (per account, 30s) would be much faster.
|
||||||
|
|
||||||
|
## Why these were deferred
|
||||||
|
|
||||||
|
The scope of "build an email plugin" is essentially "build a mail client",
|
||||||
|
which is a multi-month project on its own. The MVP proves the plugin system
|
||||||
|
can host a non-trivial feature with a backend, schema, encrypted
|
||||||
|
credentials, custom API routes, and a complex UI — all without touching
|
||||||
|
the hyprcrd core. Each item above can be added as an isolated patch to this
|
||||||
|
plugin directory, and the plugin can still be disabled in settings to fully
|
||||||
|
remove the feature.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Plugin: backend.
|
||||||
|
*
|
||||||
|
* Creates the email_accounts table on first load, encrypts passwords with
|
||||||
|
* the existing vault key, and registers /api/email/* routes via the plugin
|
||||||
|
* route hook.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/email/accounts → list accounts (no passwords)
|
||||||
|
* POST /api/email/accounts → create account
|
||||||
|
* PATCH /api/email/accounts/{id} → update (omit password to keep)
|
||||||
|
* DELETE /api/email/accounts/{id} → remove
|
||||||
|
* POST /api/email/accounts/{id}/test → test IMAP credentials
|
||||||
|
* GET /api/email/messages?account={id}&limit=N → list recent message headers
|
||||||
|
* GET /api/email/message/{accId}/{uid} → fetch + parse one message
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/ImapClient.php';
|
||||||
|
require_once __DIR__ . '/SpamDetector.php';
|
||||||
|
|
||||||
|
// ── Schema migration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 993,
|
||||||
|
encryption TEXT NOT NULL DEFAULT 'ssl',
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS email_spam_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_type TEXT NOT NULL CHECK(rule_type IN ('block','allow')),
|
||||||
|
sender_email TEXT,
|
||||||
|
sender_domain TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_email ON email_spam_rules(sender_email)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_domain ON email_spam_rules(sender_domain)");
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function emailEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailEncrypt(string $plain): string {
|
||||||
|
$key = emailEncKey();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailDecrypt(string $encoded): string {
|
||||||
|
$key = emailEncKey();
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailGetAccount(int $id): ?array {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT * FROM email_accounts WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailLoadSpamRules(): array {
|
||||||
|
$db = Database::connect();
|
||||||
|
return $db->query('SELECT rule_type, sender_email, sender_domain FROM email_spam_rules')->fetchAll() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free email providers — for these we store the specific email address
|
||||||
|
* (not the domain) when blocklisting, otherwise we'd block all gmail users.
|
||||||
|
*/
|
||||||
|
const EMAIL_FREE_PROVIDERS = [
|
||||||
|
'gmail.com', 'googlemail.com',
|
||||||
|
'outlook.com', 'hotmail.com', 'live.com', 'live.de', 'msn.com',
|
||||||
|
'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'ymail.com',
|
||||||
|
'icloud.com', 'me.com', 'mac.com',
|
||||||
|
'gmx.de', 'gmx.net', 'gmx.com', 'gmx.at', 'gmx.ch',
|
||||||
|
'web.de', 't-online.de', 'freenet.de', 'arcor.de',
|
||||||
|
'aol.com', 'aol.de',
|
||||||
|
'protonmail.com', 'proton.me', 'pm.me',
|
||||||
|
'mail.com', 'mail.ru', 'yandex.com', 'yandex.ru',
|
||||||
|
'tutanota.com', 'tuta.io', 'fastmail.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
function emailExtractSenderParts(string $fromHeader): array {
|
||||||
|
if (preg_match('/<([^>]+)>/', $fromHeader, $m)) {
|
||||||
|
$email = strtolower(trim($m[1]));
|
||||||
|
} elseif (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $fromHeader, $m)) {
|
||||||
|
$email = strtolower(trim($m[0]));
|
||||||
|
} else {
|
||||||
|
return ['email' => '', 'domain' => ''];
|
||||||
|
}
|
||||||
|
$at = strrpos($email, '@');
|
||||||
|
$domain = $at === false ? '' : substr($email, $at + 1);
|
||||||
|
return ['email' => $email, 'domain' => $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a connected, logged-in IMAP client for the given account. */
|
||||||
|
function emailOpenAccount(array $row): ImapClient {
|
||||||
|
$client = new ImapClient(
|
||||||
|
$row['host'],
|
||||||
|
(int)$row['port'],
|
||||||
|
$row['encryption'],
|
||||||
|
15
|
||||||
|
);
|
||||||
|
$client->connect();
|
||||||
|
$client->login(emailDecrypt($row['username']), emailDecrypt($row['password']));
|
||||||
|
$client->selectFolder($row['folder'] ?: 'INBOX');
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ────────────────────────────────────────────
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
|
||||||
|
// ── Accounts CRUD ──
|
||||||
|
|
||||||
|
if ($route === '/api/email/accounts' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
$rows = $db->query('SELECT id, name, host, port, encryption, folder, position, created_at FROM email_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||||
|
// Return decrypted username (NOT password) so the form can show it
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$stmt = $db->prepare('SELECT username FROM email_accounts WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $r['id']]);
|
||||||
|
$u = $stmt->fetchColumn();
|
||||||
|
$r['username'] = $u ? emailDecrypt($u) : '';
|
||||||
|
}
|
||||||
|
emailJson($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/accounts' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$host = trim((string)($input['host'] ?? ''));
|
||||||
|
$username = (string)($input['username'] ?? '');
|
||||||
|
$password = (string)($input['password'] ?? '');
|
||||||
|
$port = (int)($input['port'] ?? 993);
|
||||||
|
$encryption = in_array($input['encryption'] ?? 'ssl', ['ssl', 'tls', 'none'], true) ? $input['encryption'] : 'ssl';
|
||||||
|
$folder = trim((string)($input['folder'] ?? 'INBOX')) ?: 'INBOX';
|
||||||
|
if ($name === '' || $host === '' || $username === '' || $password === '') {
|
||||||
|
emailJson(['error' => 'name, host, username, and password are required'], 400);
|
||||||
|
}
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO email_accounts (name, host, port, encryption, username, password, folder)
|
||||||
|
VALUES (:n, :h, :p, :e, :u, :pw, :f)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
'n' => $name, 'h' => $host, 'p' => $port, 'e' => $encryption,
|
||||||
|
'u' => emailEncrypt($username), 'pw' => emailEncrypt($password), 'f' => $folder,
|
||||||
|
]);
|
||||||
|
emailJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = emailGetAccount($id);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$fields = []; $params = ['id' => $id];
|
||||||
|
foreach (['name', 'host', 'folder'] as $k) {
|
||||||
|
if (array_key_exists($k, $input)) { $fields[] = "$k = :$k"; $params[$k] = trim((string)$input[$k]); }
|
||||||
|
}
|
||||||
|
if (array_key_exists('port', $input)) { $fields[] = 'port = :port'; $params['port'] = (int)$input['port']; }
|
||||||
|
if (array_key_exists('encryption', $input) && in_array($input['encryption'], ['ssl','tls','none'], true)) {
|
||||||
|
$fields[] = 'encryption = :encryption'; $params['encryption'] = $input['encryption'];
|
||||||
|
}
|
||||||
|
if (!empty($input['username'])) { $fields[] = 'username = :username'; $params['username'] = emailEncrypt($input['username']); }
|
||||||
|
if (!empty($input['password'])) { $fields[] = 'password = :password'; $params['password'] = emailEncrypt($input['password']); }
|
||||||
|
if (!$fields) emailJson(['ok' => true]);
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('UPDATE email_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
|
emailJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('DELETE FROM email_accounts WHERE id = :id')->execute(['id' => $id]);
|
||||||
|
emailJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = emailGetAccount($id);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$info = $client->selectFolder($row['folder'] ?: 'INBOX');
|
||||||
|
$client->logout();
|
||||||
|
emailJson(['ok' => true, 'exists' => $info['exists']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ──
|
||||||
|
|
||||||
|
if ($route === '/api/email/messages' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$limit = max(1, min(200, (int)($_GET['limit'] ?? 30)));
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$uids = $client->uidSearch('ALL');
|
||||||
|
rsort($uids); // newest first
|
||||||
|
$total = count($uids);
|
||||||
|
$pageUids = array_slice($uids, $offset, $limit);
|
||||||
|
$messages = $client->fetchHeaders($pageUids);
|
||||||
|
usort($messages, fn($a, $b) => $b['uid'] - $a['uid']);
|
||||||
|
$client->logout();
|
||||||
|
|
||||||
|
// Score each message for spam (header signals only at list time —
|
||||||
|
// body checks happen on demand when the message is opened).
|
||||||
|
// Pass user-defined block/allow rules so manual feedback flows in.
|
||||||
|
$detector = new SpamDetector(emailLoadSpamRules());
|
||||||
|
foreach ($messages as &$msg) {
|
||||||
|
$verdict = $detector->score($msg['headers'] ?? []);
|
||||||
|
$msg['spam'] = $verdict;
|
||||||
|
// Don't ship the full headers map to the client (too noisy)
|
||||||
|
unset($msg['headers']);
|
||||||
|
}
|
||||||
|
unset($msg);
|
||||||
|
|
||||||
|
emailJson([
|
||||||
|
'ok' => true,
|
||||||
|
'messages' => $messages,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/spam/mark' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$accId = (int)($input['account'] ?? 0);
|
||||||
|
$uids = $input['uids'] ?? [];
|
||||||
|
$action = $input['action'] ?? '';
|
||||||
|
if (!in_array($action, ['spam', 'ham'], true)) {
|
||||||
|
emailJson(['error' => 'action must be spam or ham'], 400);
|
||||||
|
}
|
||||||
|
if (!is_array($uids) || !$uids) emailJson(['error' => 'uids array required'], 400);
|
||||||
|
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||||
|
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the From header for each UID
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$headers = $client->fetchHeaders($cleanUids);
|
||||||
|
$client->logout();
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$ruleType = $action === 'spam' ? 'block' : 'allow';
|
||||||
|
// Remove any opposing rule for the same sender first so toggling works
|
||||||
|
$oppositeType = $action === 'spam' ? 'allow' : 'block';
|
||||||
|
$delStmt = $db->prepare('DELETE FROM email_spam_rules WHERE rule_type = :t AND (sender_email = :e OR sender_domain = :d)');
|
||||||
|
$insStmt = $db->prepare('INSERT INTO email_spam_rules (rule_type, sender_email, sender_domain) VALUES (:t, :e, :d)');
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$seenSenders = [];
|
||||||
|
foreach ($headers as $h) {
|
||||||
|
$parts = emailExtractSenderParts($h['from'] ?? '');
|
||||||
|
if (!$parts['email']) continue;
|
||||||
|
|
||||||
|
// Dedupe within this request
|
||||||
|
$sigKey = $parts['email'];
|
||||||
|
if (isset($seenSenders[$sigKey])) continue;
|
||||||
|
$seenSenders[$sigKey] = true;
|
||||||
|
|
||||||
|
$isFreeProvider = in_array($parts['domain'], EMAIL_FREE_PROVIDERS, true);
|
||||||
|
// For free providers, only store the email; otherwise store both
|
||||||
|
$email = $parts['email'];
|
||||||
|
$domain = $isFreeProvider ? null : $parts['domain'];
|
||||||
|
|
||||||
|
// Drop any opposite rule
|
||||||
|
$delStmt->execute(['t' => $oppositeType, 'e' => $email, 'd' => $domain]);
|
||||||
|
// Avoid duplicate rule of the SAME type
|
||||||
|
$existsStmt = $db->prepare('SELECT 1 FROM email_spam_rules WHERE rule_type = :t AND ((sender_email IS NOT NULL AND sender_email = :e) OR (sender_domain IS NOT NULL AND sender_domain = :d)) LIMIT 1');
|
||||||
|
$existsStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||||
|
if (!$existsStmt->fetchColumn()) {
|
||||||
|
$insStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||||
|
$added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emailJson(['ok' => true, 'added' => $added, 'action' => $action]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/delete' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$accId = (int)($input['account'] ?? 0);
|
||||||
|
$uids = $input['uids'] ?? [];
|
||||||
|
if (!is_array($uids) || !$uids) {
|
||||||
|
emailJson(['error' => 'uids array required'], 400);
|
||||||
|
}
|
||||||
|
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||||
|
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$count = $client->deleteUids($cleanUids);
|
||||||
|
$client->logout();
|
||||||
|
emailJson(['ok' => true, 'deleted' => $count]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/message/(\d+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$uid = (int)$m[2];
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$msg = $client->fetchMessage($uid);
|
||||||
|
$client->logout();
|
||||||
|
// Strip HTML if no plaintext available
|
||||||
|
if (!$msg['text'] && $msg['html']) {
|
||||||
|
$msg['text'] = trim(html_entity_decode(strip_tags(preg_replace('/<br\s*\/?>/i', "\n", $msg['html']))));
|
||||||
|
}
|
||||||
|
emailJson([
|
||||||
|
'ok' => true,
|
||||||
|
'uid' => $uid,
|
||||||
|
'subject' => $msg['headers']['subject'] ?? '',
|
||||||
|
'from' => $msg['headers']['from'] ?? '',
|
||||||
|
'to' => $msg['headers']['to'] ?? '',
|
||||||
|
'date' => $msg['headers']['date'] ?? '',
|
||||||
|
'text' => $msg['text'],
|
||||||
|
'html' => $msg['html'],
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/* Email Plugin styles */
|
||||||
|
|
||||||
|
#email-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
|
||||||
|
|
||||||
|
.email-modal {
|
||||||
|
/* defined inline for sizing */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message list — no text selection inside (drag is for multi-select) */
|
||||||
|
#email-list, #email-list * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message list rows */
|
||||||
|
.email-msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.email-msg-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
}
|
||||||
|
.email-msg-row.active {
|
||||||
|
background: var(--t-raised);
|
||||||
|
border-left-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.email-msg-row.unseen {
|
||||||
|
border-left-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.email-msg-row.selected {
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spam levels — suspicious yellow, spam red. Override unseen border. */
|
||||||
|
.email-msg-row.spam-suspicious {
|
||||||
|
border-left-color: #fbbf24 !important; /* amber */
|
||||||
|
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-suspicious:hover {
|
||||||
|
background: color-mix(in srgb, #fbbf24 14%, var(--t-raised));
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam {
|
||||||
|
border-left-color: var(--t-danger) !important;
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam:hover {
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 20%, var(--t-raised));
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam .email-msg-subject {
|
||||||
|
color: var(--t-danger);
|
||||||
|
}
|
||||||
|
.email-spam-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.email-spam-badge.spam {
|
||||||
|
background: var(--t-danger);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
.email-spam-badge.suspicious {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.email-msg-check {
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--t-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.email-msg-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.email-msg-row.unseen .email-msg-subject {
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-msg-from {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.email-msg-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.email-msg-date {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account list (in management view) */
|
||||||
|
.email-account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-name {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.email-account-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / empty states */
|
||||||
|
.email-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#email-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||||
|
|
||||||
|
/* Override Chrome/Safari autofill styling on the account form inputs */
|
||||||
|
#email-acc-username:-webkit-autofill,
|
||||||
|
#email-acc-username:-webkit-autofill:hover,
|
||||||
|
#email-acc-username:-webkit-autofill:focus,
|
||||||
|
#email-acc-password:-webkit-autofill,
|
||||||
|
#email-acc-password:-webkit-autofill:hover,
|
||||||
|
#email-acc-password:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: var(--t-bright);
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
caret-color: var(--t-bright);
|
||||||
|
transition: background-color 9999s ease-out, color 9999s ease-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!-- Email Plugin: full-screen mail viewer overlay -->
|
||||||
|
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
||||||
|
<div class="email-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||||
|
<div id="email-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||||
|
<div class="email-welcome text-center max-w-md">
|
||||||
|
<i class="ph ph-envelope text-t-accent text-6xl block mb-4"></i>
|
||||||
|
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to mail</h3>
|
||||||
|
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||||
|
Read mail from any IMAP account inside hyprcrd. Messages stay on the
|
||||||
|
server — only the ones you explicitly extract become cards.
|
||||||
|
To get started, add at least one account in settings.
|
||||||
|
</p>
|
||||||
|
<button id="email-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||||
|
<i class="ph ph-gear text-sm"></i>
|
||||||
|
<span class="font-pixel text-[11px]">Configure accounts</span>
|
||||||
|
</button>
|
||||||
|
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → mail</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mail viewer (default view when accounts exist) ── -->
|
||||||
|
<div id="email-view-mail" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||||
|
<!-- Message list -->
|
||||||
|
<div id="email-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:38%;">
|
||||||
|
<div class="sticky top-0 z-20 bg-t-bg">
|
||||||
|
<!-- 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">
|
||||||
|
<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" 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>
|
||||||
|
<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" data-tip="select spam">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Selection action bar (visible when at least one selected) -->
|
||||||
|
<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>
|
||||||
|
<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" data-tip="mark as spam">
|
||||||
|
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
|
||||||
|
</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" data-tip="not spam">
|
||||||
|
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
|
||||||
|
</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" data-tip="Delete selected on server">
|
||||||
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
|
||||||
|
</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" data-tip="Clear selection">
|
||||||
|
<i class="ph ph-x text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="email-list" class="divide-y divide-t-border"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="email-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||||
|
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Message body -->
|
||||||
|
<div id="email-body-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||||
|
<div id="email-body-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="ph ph-envelope-open text-3xl mb-2 block"></i>
|
||||||
|
<span class="cursor-blink">select a message</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="email-body" class="hidden flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<h3 id="email-msg-subject" class="text-t-bright text-base font-bold mb-2"></h3>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] font-pixel text-t-dim">
|
||||||
|
<span><span class="text-t-muted">from:</span> <span id="email-msg-from" class="text-t-text"></span></span>
|
||||||
|
<span><span class="text-t-muted">date:</span> <span id="email-msg-date" class="text-t-text"></span></span>
|
||||||
|
</div>
|
||||||
|
<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-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" 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" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="email-msg-text" class="flex-1 min-h-0 overflow-y-auto text-xs text-t-text whitespace-pre-wrap break-words font-mono leading-relaxed px-5 py-4 m-0"></pre>
|
||||||
|
<iframe id="email-msg-html" class="hidden flex-1 min-h-0 w-full bg-white" sandbox="allow-popups allow-popups-to-escape-sandbox" style="border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read IMAP mail accounts in a split-pane viewer. Extract messages as cards. Multiple accounts supported, passwords encrypted with the vault key.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["email.js"],
|
||||||
|
"css": ["email.css"],
|
||||||
|
"html": ["email.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// ── Example content plugin — one-click demo data ──
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const api = h?.api || window._exampleApi;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
blue: '#38bdf8', purple: '#a78bfa', pink: '#f472b6', red: '#fb7185',
|
||||||
|
green: '#34d399', yellow: '#fbbf24', orange: '#fb923c', slate: '#64748b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{
|
||||||
|
title: 'Architecture RFC',
|
||||||
|
color: COLORS.blue,
|
||||||
|
body: `## System Architecture
|
||||||
|
|
||||||
|
The new microservice layout separates auth, data, and notification layers.
|
||||||
|
|
||||||
|
### Key decisions
|
||||||
|
- PostgreSQL for persistent state
|
||||||
|
- Redis for session cache + pub/sub
|
||||||
|
- gRPC between internal services
|
||||||
|
- REST + WebSocket for client API
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
- [ ] Rate limiting strategy
|
||||||
|
- [ ] Multi-region failover
|
||||||
|
- [x] Auth token rotation scheme
|
||||||
|
- [x] Database migration tooling
|
||||||
|
|
||||||
|
See also: [[Deploy checklist]]
|
||||||
|
|
||||||
|
#project/api #architecture`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sprint Planning Q3',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `## Goals
|
||||||
|
|
||||||
|
1. Finish API v3 migration
|
||||||
|
2. Launch mobile auth flow
|
||||||
|
3. Load testing at 10k concurrent
|
||||||
|
4. Cut staging→prod deploy time to <5min
|
||||||
|
|
||||||
|
**Deadline:** 2026-07-01
|
||||||
|
**Owner:** @alice
|
||||||
|
|
||||||
|
### Capacity
|
||||||
|
| Team | Points | Allocated |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Backend | 34 | 30 |
|
||||||
|
| Frontend | 21 | 18 |
|
||||||
|
| Infra | 13 | 13 |
|
||||||
|
|
||||||
|
#meeting #project`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Auth timeout on mobile',
|
||||||
|
color: COLORS.red,
|
||||||
|
body: `Users on iOS Safari report being logged out after ~3 minutes of inactivity.
|
||||||
|
|
||||||
|
### Repro
|
||||||
|
1. Login on iPhone 15 (Safari 18)
|
||||||
|
2. Switch to another app for 3 min
|
||||||
|
3. Return → "session expired"
|
||||||
|
|
||||||
|
### Investigation
|
||||||
|
- Cookie \`SameSite=Strict\` might be too aggressive
|
||||||
|
- Safari's ITP may be purging the session cookie
|
||||||
|
- Backend session TTL is 24h — not the issue
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Try \`SameSite=Lax\` + short-lived refresh token.
|
||||||
|
|
||||||
|
#bug #urgent #project/frontend/auth`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Deploy checklist',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `- [x] Run full test suite
|
||||||
|
- [x] Update CHANGELOG.md
|
||||||
|
- [x] Bump version in config
|
||||||
|
- [ ] Tag release in git
|
||||||
|
- [ ] Build Docker image
|
||||||
|
- [ ] Push to registry
|
||||||
|
- [ ] Roll out to staging
|
||||||
|
- [ ] Smoke test staging
|
||||||
|
- [ ] Promote to production
|
||||||
|
- [ ] Monitor Grafana for 30min
|
||||||
|
|
||||||
|
> Last deploy: 2026-04-08 by @bob
|
||||||
|
> Rollback: \`git revert HEAD && ./deploy.sh\`
|
||||||
|
|
||||||
|
#ops #deploy`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Meeting 2026-04-15 — Retro',
|
||||||
|
color: COLORS.yellow,
|
||||||
|
due_date: '2026-04-15',
|
||||||
|
body: `**Zeit:** 14:00-15:00
|
||||||
|
**Ort:** Room 42
|
||||||
|
**Teilnehmer:** Alice, Bob, Carol
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### What went well
|
||||||
|
- Deploy frequency improved (3x/week → daily)
|
||||||
|
- Zero P1 incidents this sprint
|
||||||
|
- New search feature got good feedback
|
||||||
|
|
||||||
|
### What to improve
|
||||||
|
- PR review turnaround still >24h
|
||||||
|
- Flaky integration tests causing false alarms
|
||||||
|
- Documentation gaps in relay module
|
||||||
|
|
||||||
|
### Action items
|
||||||
|
- [ ] @alice: set up PR review SLA
|
||||||
|
- [ ] @bob: fix top 5 flaky tests
|
||||||
|
- [ ] @carol: write relay onboarding doc
|
||||||
|
|
||||||
|
#meeting #termin #retro`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reading list',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `Papers and articles to read:
|
||||||
|
|
||||||
|
- [Designing Data-Intensive Applications](https://dataintensive.net/) — Ch. 7-9
|
||||||
|
- [The Twelve-Factor App](https://12factor.net/)
|
||||||
|
- \`CAP theorem revisited\` — Brewer 2012
|
||||||
|
- Local-first software — Kleppmann et al.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
> "The truth is the log. The database is a cache."
|
||||||
|
> — Pat Helland
|
||||||
|
|
||||||
|
#personal #research`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'API v3 migration',
|
||||||
|
color: COLORS.blue,
|
||||||
|
body: `### Endpoints to migrate
|
||||||
|
|
||||||
|
| v2 | v3 | Status |
|
||||||
|
|----|-----|--------|
|
||||||
|
| GET /users | GET /api/v3/users | done |
|
||||||
|
| POST /auth | POST /api/v3/auth/token | done |
|
||||||
|
| GET /cards | GET /api/v3/cards | in progress |
|
||||||
|
| WS /stream | WS /api/v3/events | planned |
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
- Auth header: \`Bearer\` instead of \`X-Token\`
|
||||||
|
- Pagination: cursor-based instead of offset
|
||||||
|
- Error format: \`{"error": {"code": ..., "message": ...}}\`
|
||||||
|
|
||||||
|
[[Architecture RFC]]
|
||||||
|
|
||||||
|
#project/api #migration`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Infra cost review',
|
||||||
|
color: COLORS.orange,
|
||||||
|
body: `### Monthly breakdown (April)
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
Compute (k8s) €340 ████████░░ 68%
|
||||||
|
Storage (S3) €85 ██░░░░░░░░ 17%
|
||||||
|
Database (RDS) €52 █░░░░░░░░░ 10%
|
||||||
|
CDN + DNS €18 ░░░░░░░░░░ 4%
|
||||||
|
Monitoring €8 ░░░░░░░░░░ 2%
|
||||||
|
─────────────────────────────────────
|
||||||
|
Total €503
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Budget: €600 → **€97 headroom**
|
||||||
|
|
||||||
|
Next action: evaluate spot instances for batch workers.
|
||||||
|
|
||||||
|
#ops #infra #finance`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Birthday Alice',
|
||||||
|
color: COLORS.pink,
|
||||||
|
due_date: '2026-06-12',
|
||||||
|
body: `#geburtstag`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Onboarding: new developer',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `## First week checklist
|
||||||
|
|
||||||
|
- [ ] Laptop + accounts provisioned
|
||||||
|
- [ ] SSH key added to Gitea
|
||||||
|
- [ ] Access to staging cluster
|
||||||
|
- [x] Slack channels joined
|
||||||
|
- [x] 1:1 with team lead
|
||||||
|
- [ ] Read [[Architecture RFC]]
|
||||||
|
- [ ] First PR: fix a good-first-issue
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
| Role | Person |
|
||||||
|
|------|--------|
|
||||||
|
| Buddy | @carol |
|
||||||
|
| Team lead | @alice |
|
||||||
|
| Infra | @dave |
|
||||||
|
|
||||||
|
#onboarding #ops`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Competitive analysis',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `### Landscape
|
||||||
|
|
||||||
|
| Product | Self-hosted | Offline | E2E encryption |
|
||||||
|
|---------|:-----------:|:-------:|:--------------:|
|
||||||
|
| Notion | no | no | no |
|
||||||
|
| Obsidian| yes | yes | paid |
|
||||||
|
| Logseq | yes | yes | no |
|
||||||
|
| **hyprcrd** | **yes** | **yes** | **yes** |
|
||||||
|
|
||||||
|
### Differentiators
|
||||||
|
- Tag extraction from body (zero-friction categorization)
|
||||||
|
- Relay model: multi-tenant sync without cloud dependency
|
||||||
|
- Plugin system: extensible without forking
|
||||||
|
- CalDAV native (not just ical export)
|
||||||
|
|
||||||
|
#research #strategy`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Security audit notes',
|
||||||
|
color: COLORS.red,
|
||||||
|
body: `## Audit 2026-Q1 findings
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
~~1. Session token in URL parameter~~ → fixed in v1.2.1
|
||||||
|
|
||||||
|
### High
|
||||||
|
2. CSRF token not rotated on privilege change → [[Auth timeout on mobile]]
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
3. Upload directory allows .svg (XSS vector)
|
||||||
|
4. Rate limiter not applied to /api/export
|
||||||
|
|
||||||
|
### Low
|
||||||
|
5. Server version exposed in HTTP headers
|
||||||
|
6. No CSP header on main app
|
||||||
|
|
||||||
|
> Next audit scheduled: 2026-07-15
|
||||||
|
|
||||||
|
#security #audit #urgent`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Design system tokens',
|
||||||
|
color: COLORS.purple,
|
||||||
|
body: `### Color palette
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
--t-bg: #0f172a base background
|
||||||
|
--t-surface: #1e293b card/panel surface
|
||||||
|
--t-border: #334155 borders
|
||||||
|
--t-text: #94a3b8 body text
|
||||||
|
--t-bright: #e2e8f0 headlines
|
||||||
|
--t-accent: #38bdf8 primary accent
|
||||||
|
--t-accent2: #a78bfa secondary accent
|
||||||
|
--t-danger: #fb7185 errors/destructive
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- **UI:** Silkscreen (pixel font)
|
||||||
|
- **Content:** JetBrains Mono
|
||||||
|
- **Sizes:** 9px labels, 11px body, 13px headings
|
||||||
|
|
||||||
|
### Radius scale
|
||||||
|
\`--ui-radius-card\`, \`--ui-radius-modal\`, \`--ui-radius-input\`
|
||||||
|
|
||||||
|
All overridable per theme preset.
|
||||||
|
|
||||||
|
#design #frontend`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Customer feedback digest',
|
||||||
|
color: COLORS.yellow,
|
||||||
|
body: `### Week of 2026-04-07
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- "Search is blazing fast" — 3 mentions
|
||||||
|
- "CalDAV sync just works" — 2 mentions
|
||||||
|
- "Love the keyboard shortcuts" — @user42
|
||||||
|
|
||||||
|
**Requests:**
|
||||||
|
- Mobile app (→ Electron PWA?)
|
||||||
|
- Shared boards without relay (→ collab-buckets?)
|
||||||
|
- Dark mode for relay UI (→ already shipped!)
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Image upload fails >5MB on shared hosting
|
||||||
|
- Calendar popup overlaps on 13" screens
|
||||||
|
|
||||||
|
#feedback #product`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Database schema notes',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `### Core tables
|
||||||
|
|
||||||
|
\`\`\`sql
|
||||||
|
cards (id, title, body, color, tags,
|
||||||
|
bucket_id, parent_id, position,
|
||||||
|
due_date, relay_uid,
|
||||||
|
created_at, updated_at)
|
||||||
|
|
||||||
|
buckets (id, name, color, parent_id,
|
||||||
|
collab_uid, position)
|
||||||
|
|
||||||
|
settings (key, value)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
- \`cards.bucket_id\` — bucket filter
|
||||||
|
- \`cards.parent_id\` — group lookup
|
||||||
|
- \`buckets.collab_uid UNIQUE\` — relay sync
|
||||||
|
|
||||||
|
SQLite WAL mode for concurrent reads.
|
||||||
|
|
||||||
|
#architecture #database`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Release notes v1.3',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `## What's new
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Tooltip system** — custom styled tooltips replace all native titles
|
||||||
|
- **Asset picker** — thumbnail grid in the editor toolbar
|
||||||
|
- **Super-bucket split** — click activates, caret opens dropdown
|
||||||
|
- **Watch plugin** — analog clock overlay with SVG watchface
|
||||||
|
- **Card peek** — hover preview with rendered markdown
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Shortcuts no longer fire when typing in inputs
|
||||||
|
- Gallery opens even if API fetch fails
|
||||||
|
- Relay poll suppressed during user actions
|
||||||
|
- Find/replace integrated into search overlay
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- All \`title=""\` attributes migrated to \`data-tip=""\`
|
||||||
|
|
||||||
|
#release #changelog`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2026-04-12',
|
||||||
|
color: COLORS.slate,
|
||||||
|
body: `## Journal
|
||||||
|
|
||||||
|
Started the day reviewing PRs. Merged the tooltip system and the asset picker.
|
||||||
|
|
||||||
|
Spent the afternoon on the Electron build — static PHP binary works but the WAL/SHM files caused issues. Fixed by deleting stale lock files.
|
||||||
|
|
||||||
|
Had a good session on the watch plugin. The wobble animation on the second hand is a nice touch.
|
||||||
|
|
||||||
|
**Tomorrow:**
|
||||||
|
- [ ] Test relay collab-buckets with two instances
|
||||||
|
- [ ] Write release notes
|
||||||
|
- [ ] Screenshot for website
|
||||||
|
|
||||||
|
#journal`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Backup strategy',
|
||||||
|
color: COLORS.orange,
|
||||||
|
body: `### Current
|
||||||
|
|
||||||
|
Daily cron:
|
||||||
|
\`\`\`bash
|
||||||
|
#!/bin/bash
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
cp data/hyprcrd.db "/backup/hyprcrd-$DATE.db"
|
||||||
|
tar czf "/backup/uploads-$DATE.tar.gz" public/uploads/
|
||||||
|
find /backup -mtime +30 -delete
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- [ ] Automated export ZIP via API
|
||||||
|
- [ ] Offsite copy to S3-compatible storage
|
||||||
|
- [ ] Test restore monthly
|
||||||
|
- [ ] Document in runbook
|
||||||
|
|
||||||
|
> WAL checkpoint before copy:
|
||||||
|
> \`sqlite3 data/hyprcrd.db "PRAGMA wal_checkpoint(TRUNCATE);"\`
|
||||||
|
|
||||||
|
#ops #backup #infra`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugin ideas',
|
||||||
|
color: COLORS.pink,
|
||||||
|
body: `### Brainstorm
|
||||||
|
|
||||||
|
- **Kanban columns** — cards as swimlanes per bucket
|
||||||
|
- **Time tracking** — start/stop timer per card, weekly report
|
||||||
|
- **AI summary** — auto-generate card summaries with local LLM
|
||||||
|
- **RSS reader** — fetch feeds into cards
|
||||||
|
- **Webhook triggers** — POST on card create/update/delete
|
||||||
|
- **PDF export** — render card as styled PDF
|
||||||
|
- **Voice memo** — record audio, attach to card, transcribe
|
||||||
|
|
||||||
|
### Criteria for inclusion
|
||||||
|
1. Does it help *find* information?
|
||||||
|
2. Can it be a drop-in plugin (no core changes)?
|
||||||
|
3. Is it useful without an internet connection?
|
||||||
|
|
||||||
|
#idea #plugins #brainstorm`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Monitoring setup',
|
||||||
|
color: COLORS.green,
|
||||||
|
body: `### Stack
|
||||||
|
|
||||||
|
- **Uptime:** UptimeRobot (free tier, 5min interval)
|
||||||
|
- **Errors:** Sentry self-hosted → \`sentry.internal\`
|
||||||
|
- **Metrics:** Grafana + Prometheus
|
||||||
|
- **Logs:** Loki + promtail on each node
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
| Metric | Threshold | Channel |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| HTTP 5xx rate | >1% for 5min | Slack #ops-alerts |
|
||||||
|
| Response time p95 | >2s for 10min | Slack #ops-alerts |
|
||||||
|
| Disk usage | >85% | Email + Slack |
|
||||||
|
| SSL expiry | <14 days | Email |
|
||||||
|
|
||||||
|
Dashboard: \`grafana.internal/d/hyprcrd\`
|
||||||
|
|
||||||
|
#ops #monitoring #infra`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const bucket = await api('api/buckets', { method: 'POST', body: { name: 'Project Alpha' } });
|
||||||
|
const bid = bucket?.id;
|
||||||
|
if (!bid) return false;
|
||||||
|
await api(`api/buckets/${bid}`, { method: 'PATCH', body: { color: COLORS.blue } });
|
||||||
|
|
||||||
|
for (const c of CARDS) {
|
||||||
|
await api('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { title: c.title, body: c.body, color: c.color, bucket_id: bid, due_date: c.due_date || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const footer = document.getElementById('main-footer');
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||||
|
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||||
|
btn.dataset.tip = 'Create example bucket with demo cards';
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="ph ph-spinner text-sm"></i><span class="font-pixel text-[10px]">creating...</span>';
|
||||||
|
const ok = await generate();
|
||||||
|
if (ok) {
|
||||||
|
btn.innerHTML = '<i class="ph ph-check text-sm"></i><span class="font-pixel text-[10px]">Done</span>';
|
||||||
|
await window._reloadAll?.();
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Failed</span>';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstChild = footer.querySelector('div');
|
||||||
|
if (firstChild) firstChild.appendChild(btn);
|
||||||
|
|
||||||
|
h?.registerPlugin({ name: 'example-content', version: '1.0.0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "example-content",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "One-click demo content for screenshots and first-time exploration.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["example-content.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Gitea v1 REST client for the hyprcrd gitea plugin.
|
||||||
|
*
|
||||||
|
* Mirrors the Python `gitea_client.py` shape but only implements the
|
||||||
|
* subset needed for MVP: connection test, search/list/get issues, list
|
||||||
|
* comments. Write operations (create issue, comment, edit) are deferred
|
||||||
|
* to a later iteration — see TODO.md.
|
||||||
|
*
|
||||||
|
* Authentication is via the standard Gitea token header:
|
||||||
|
* Authorization: token <TOKEN>
|
||||||
|
*
|
||||||
|
* Errors throw GiteaApiException with status code + body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GiteaApiException extends RuntimeException
|
||||||
|
{
|
||||||
|
public int $statusCode;
|
||||||
|
public string $body;
|
||||||
|
|
||||||
|
public function __construct(int $statusCode, string $body)
|
||||||
|
{
|
||||||
|
$this->statusCode = $statusCode;
|
||||||
|
$this->body = $body;
|
||||||
|
parent::__construct("Gitea API HTTP $statusCode: " . substr($body, 0, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GiteaClient
|
||||||
|
{
|
||||||
|
private string $baseUrl;
|
||||||
|
private string $token;
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct(string $baseUrl, string $token, int $timeout = 20)
|
||||||
|
{
|
||||||
|
$this->baseUrl = rtrim($baseUrl, '/');
|
||||||
|
$this->token = $token;
|
||||||
|
$this->timeout = $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the token works against the configured base URL by hitting
|
||||||
|
* /user. Returns the authenticated user dict on success.
|
||||||
|
* Throws GiteaApiException on failure.
|
||||||
|
*/
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
$resp = $this->request('GET', '/user');
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-repo issue search. Mirrors Python search_issues().
|
||||||
|
* @param array $opts Any of: state, labels, milestones, q, type,
|
||||||
|
* since, before, assigned, created, mentioned,
|
||||||
|
* review_requested, reviewed, owner, team,
|
||||||
|
* page, limit
|
||||||
|
*/
|
||||||
|
public function searchIssues(array $opts = []): array
|
||||||
|
{
|
||||||
|
$resp = $this->request('GET', '/repos/issues/search', $opts);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listRepoIssues(string $owner, string $repo, array $opts = []): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
|
||||||
|
$resp = $this->request('GET', $path, $opts);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIssue(string $owner, string $repo, int $index): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
|
||||||
|
$resp = $this->request('GET', $path);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listIssueComments(string $owner, string $repo, int $index, array $opts = []): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
|
||||||
|
$resp = $this->request('GET', $path, $opts);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post a new comment on an issue. Returns the created comment object. */
|
||||||
|
public function createIssueComment(string $owner, string $repo, int $index, string $body): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
|
||||||
|
$resp = $this->request('POST', $path, [], ['body' => $body]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an issue. Pass any subset of: title, body, state ('open'|'closed'),
|
||||||
|
* assignees (string[]), milestone (int), due_date (RFC3339), unset_due_date (bool).
|
||||||
|
* Returns the updated issue.
|
||||||
|
*/
|
||||||
|
public function editIssue(string $owner, string $repo, int $index, array $changes): array
|
||||||
|
{
|
||||||
|
$allowed = ['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'ref', 'unset_due_date'];
|
||||||
|
$payload = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $changes)) $payload[$k] = $changes[$k];
|
||||||
|
}
|
||||||
|
if (!$payload) return [];
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
|
||||||
|
$resp = $this->request('PATCH', $path, [], $payload);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the labels of an issue. $labels is an array of label IDs (ints).
|
||||||
|
* Returns the resulting list of labels.
|
||||||
|
*/
|
||||||
|
public function replaceIssueLabels(string $owner, string $repo, int $index, array $labels): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/labels';
|
||||||
|
$resp = $this->request('PUT', $path, [], ['labels' => array_map('intval', $labels)]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the available labels for a repository. */
|
||||||
|
public function listRepoLabels(string $owner, string $repo, int $page = 1, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/labels';
|
||||||
|
$resp = $this->request('GET', $path, ['page' => $page, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new issue in a repository. Pass any of: title (required),
|
||||||
|
* body, assignees (string[]), labels (int[] of label IDs), milestone (int),
|
||||||
|
* due_date (RFC3339), ref, closed (bool).
|
||||||
|
* Returns the created issue.
|
||||||
|
*/
|
||||||
|
public function createIssue(string $owner, string $repo, array $opts): array
|
||||||
|
{
|
||||||
|
if (empty($opts['title'])) return [];
|
||||||
|
$allowed = ['title', 'body', 'assignees', 'labels', 'milestone', 'due_date', 'ref', 'closed'];
|
||||||
|
$payload = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $opts) && $opts[$k] !== null) $payload[$k] = $opts[$k];
|
||||||
|
}
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
|
||||||
|
$resp = $this->request('POST', $path, [], $payload);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List repositories the authenticated user has access to.
|
||||||
|
* Used to populate the repo picker for "new issue".
|
||||||
|
*/
|
||||||
|
public function listMyRepos(int $page = 1, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$resp = $this->request('GET', '/user/repos', ['page' => $page, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List milestones for a repo (state: 'open' | 'closed' | 'all'). */
|
||||||
|
public function listRepoMilestones(string $owner, string $repo, string $state = 'open', int $limit = 100): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/milestones';
|
||||||
|
$resp = $this->request('GET', $path, ['state' => $state, 'page' => 1, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List who can be assigned to issues in this repo. This is the correct
|
||||||
|
* endpoint for the "assignee picker": it includes the repo owner, org
|
||||||
|
* members (when the repo is in an org), AND explicit collaborators —
|
||||||
|
* unlike `/collaborators` which only returns explicit ones.
|
||||||
|
*/
|
||||||
|
public function listRepoAssignees(string $owner, string $repo, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/assignees';
|
||||||
|
$resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List labels defined at the org level. These are inherited by repos in
|
||||||
|
* the org but DON'T appear in `/repos/{owner}/{repo}/labels`.
|
||||||
|
* 404 if the owner is a user (not an org).
|
||||||
|
*/
|
||||||
|
public function listOrgLabels(string $org, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$path = '/orgs/' . rawurlencode($org) . '/labels';
|
||||||
|
$resp = $this->request('GET', $path, ['page' => 1, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Low-level request ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
||||||
|
{
|
||||||
|
$url = $this->baseUrl . '/api/v1' . $path;
|
||||||
|
$cleanQuery = [];
|
||||||
|
foreach ($query as $k => $v) {
|
||||||
|
if ($v === null || $v === '') continue;
|
||||||
|
if (is_bool($v)) $v = $v ? 'true' : 'false';
|
||||||
|
$cleanQuery[$k] = $v;
|
||||||
|
}
|
||||||
|
if ($cleanQuery) {
|
||||||
|
$url .= '?' . http_build_query($cleanQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = [
|
||||||
|
'Accept: application/json',
|
||||||
|
'Authorization: token ' . $this->token,
|
||||||
|
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
||||||
|
];
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => $this->timeout,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
];
|
||||||
|
if ($payload !== null) {
|
||||||
|
$headers[] = 'Content-Type: application/json';
|
||||||
|
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
|
||||||
|
}
|
||||||
|
$opts[CURLOPT_HTTPHEADER] = $headers;
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
throw new RuntimeException("Could not reach Gitea: $err");
|
||||||
|
}
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new GiteaApiException((int)$status, (string)$body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body === '' || $body === null) return null;
|
||||||
|
if (stripos($contentType, 'application/json') !== false || preg_match('/^\s*[\[\{]/', (string)$body)) {
|
||||||
|
$decoded = json_decode((string)$body, true);
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# gitea plugin — roadmap
|
||||||
|
|
||||||
|
> **Status:** scaffold. Phase 1 = accounts CRUD + connection test only.
|
||||||
|
> The `gitea-issue-client/` Python tool in the project root is the
|
||||||
|
> reference for the full feature surface that needs to be ported.
|
||||||
|
|
||||||
|
## Phase 1 — done ✅
|
||||||
|
|
||||||
|
- [x] `GiteaClient.php` cURL wrapper with `testConnection()`,
|
||||||
|
`searchIssues()`, `listRepoIssues()`, `getIssue()`, `listIssueComments()`
|
||||||
|
- [x] `gitea_accounts` table with encrypted token (vault key, same as email)
|
||||||
|
- [x] Routes: `GET/POST/PATCH/DELETE /api/gitea/accounts` + `POST .../test`
|
||||||
|
- [x] Settings tab via `hyprcrd.settings.registerTab`
|
||||||
|
- [x] Header button "gitea"
|
||||||
|
- [x] Welcome view + accounts management form
|
||||||
|
- [x] Connection test against `/api/v1/user`
|
||||||
|
|
||||||
|
## Phase 2 — issue reader ✅
|
||||||
|
|
||||||
|
- [x] **Issue list view** in the overlay:
|
||||||
|
- Search box (`q` parameter, debounced 350ms)
|
||||||
|
- State filter (open/closed/all)
|
||||||
|
- Pagination (prev/next, hasMore heuristic since Gitea doesn't always return total)
|
||||||
|
- Each row: title, repo path, state badge, labels, author, age
|
||||||
|
- [x] **Issue detail view** (right pane):
|
||||||
|
- Subject + state badge + labels + #number + repo
|
||||||
|
- Body (markdown rendered via `hyprcrd.markdown.render` — exposed by core)
|
||||||
|
- Comments list (each with author + relative timestamp + rendered markdown)
|
||||||
|
- "Open on Gitea" link button
|
||||||
|
- [x] **Extract → card**:
|
||||||
|
- Card title = `[repo#number] title`
|
||||||
|
- Card body = metadata header + body + comments + `#gitea #issue` tags
|
||||||
|
- Color: green (#22c55e)
|
||||||
|
- [ ] **Issue → card backlink** in a `gitea_issue_links` table — needed
|
||||||
|
for Phase 5 bidirectional sync
|
||||||
|
|
||||||
|
## Phase 3 — write operations
|
||||||
|
|
||||||
|
- [x] **Add comment** — composer textarea always visible at the bottom of the
|
||||||
|
detail pane. Ctrl/Cmd+Enter posts. New comment is appended to local state
|
||||||
|
and re-rendered immediately.
|
||||||
|
- [x] **Close / reopen** — single button in the action bar that toggles state.
|
||||||
|
Updates the badge, button label, and the row in the list.
|
||||||
|
- [x] **Edit title** — pencil icon next to the title swaps to an inline input.
|
||||||
|
Enter saves, Escape cancels. PATCH `/issues/{index}`.
|
||||||
|
- [x] **Edit body** — pencil icon next to the body swaps to a textarea.
|
||||||
|
Ctrl/Cmd+Enter saves, Escape cancels.
|
||||||
|
- [x] **Set labels** — tag icon opens an inline editor that fetches the repo's
|
||||||
|
label list (`GET /api/gitea/repo/.../labels`) and shows checkboxes with
|
||||||
|
original colors. Save uses `PUT /issues/{index}/labels`.
|
||||||
|
- [ ] **Set assignees** — needs a collaborator typeahead (next round, see
|
||||||
|
TODO Phase 3.5)
|
||||||
|
- [ ] **Set milestone** — needs milestone fetch + dropdown (next round)
|
||||||
|
- [ ] **Set due date** — date picker (next round)
|
||||||
|
|
||||||
|
## Phase 3.5 — remaining metadata
|
||||||
|
|
||||||
|
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` (added with create form)
|
||||||
|
- [x] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` (added with create form)
|
||||||
|
- [x] Milestone dropdown — currently in **create** form only
|
||||||
|
- [x] Assignee multi-select — currently in **create** form only
|
||||||
|
- [x] Same metadata editors in the **detail** view: combined inline editor
|
||||||
|
that lets you change assignees, milestone, and due date all at once.
|
||||||
|
Single PATCH call handles all three. Re-fetches the issue after save
|
||||||
|
to keep state in sync.
|
||||||
|
- [x] Due date input + clear button (create skipped due_date but edit has it)
|
||||||
|
- [ ] Due date in the **create** form too (currently only on edit)
|
||||||
|
- [ ] **Project assignment** — gitea's API does NOT include `project` in
|
||||||
|
`CreateIssueOption` or `EditIssueOption`. Issues are added to projects
|
||||||
|
via separate board endpoints which differ across gitea versions. Punt
|
||||||
|
until there's a clear API surface to target.
|
||||||
|
|
||||||
|
## Phase 4 — create issues
|
||||||
|
|
||||||
|
- [x] **"new issue" button** in the list toolbar
|
||||||
|
- [x] **Repo picker** loaded from `/user/repos` (cached per account, filtered
|
||||||
|
to non-archived repos with push permission)
|
||||||
|
- [x] **Title + body + labels** form
|
||||||
|
- [x] **Submit** creates via POST `/repo/{accId}/{owner}/{repo}/issues`,
|
||||||
|
refreshes list, auto-opens the new issue in the detail view
|
||||||
|
- [ ] **Create from card**: a card → push as new gitea issue, store the
|
||||||
|
backref so future edits can sync both ways (Phase 5)
|
||||||
|
- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md`
|
||||||
|
if available (next round)
|
||||||
|
|
||||||
|
## Phase 5 — bidirectional sync (the killer feature)
|
||||||
|
|
||||||
|
- [ ] When a card has a gitea backref, an "issue" badge appears
|
||||||
|
- [ ] Edit the card body in hyprcrd → "push to gitea" button
|
||||||
|
- [ ] "pull from gitea" button refreshes the card from the live issue
|
||||||
|
- [ ] Conflict detection via `updated_at` timestamps
|
||||||
|
- [ ] This replaces the Python tool's `gir push` workflow
|
||||||
|
|
||||||
|
## Phase 6 — multi-instance + ergonomics
|
||||||
|
|
||||||
|
- [ ] Account selector remembers last-used account in localStorage
|
||||||
|
- [ ] Per-account default filters (e.g. always show `assigned=true`)
|
||||||
|
- [ ] Org/repo browsing tree on the left
|
||||||
|
- [ ] Label browser per repo
|
||||||
|
- [ ] Milestone view
|
||||||
|
- [ ] Search across multiple accounts
|
||||||
|
|
||||||
|
## Hardening
|
||||||
|
|
||||||
|
- [ ] Rate limiting on test endpoint
|
||||||
|
- [ ] Better error messages (currently raw HTTP body leaks through)
|
||||||
|
- [ ] Cert pinning option for self-hosted gitea instances
|
||||||
|
- [ ] Connection caching (currently every request opens a new cURL handle)
|
||||||
|
- [ ] Map common errors (401 → "token expired", 403 → "no access", etc.)
|
||||||
|
|
||||||
|
## Mapping from the Python tool
|
||||||
|
|
||||||
|
| Python feature | hyprcrd plan |
|
||||||
|
|----------------------------------------|-------------------------------------------|
|
||||||
|
| `gi search` | overlay issue list with `q` filter |
|
||||||
|
| `gi list --owner --repo` | overlay with owner/repo filter |
|
||||||
|
| `gi get` | overlay detail pane |
|
||||||
|
| `gi create` | Phase 4 "new issue" button |
|
||||||
|
| `gi edit` | Phase 3 detail-pane edit buttons |
|
||||||
|
| `gi comment` | Phase 3 comment box in detail pane |
|
||||||
|
| `gi comments` | Phase 2 comments rendering |
|
||||||
|
| `gir fetch` → markdown files | Phase 2 extract-as-card (no files needed) |
|
||||||
|
| `gir push` → sync local edits | Phase 5 bidirectional sync |
|
||||||
|
| `gir new --push` | Phase 4 create-from-card |
|
||||||
|
| `gir clean` | not needed (cards are managed via the UI) |
|
||||||
|
| `_valid-metadata.md` per repo | label/milestone dropdowns at edit time |
|
||||||
|
| `templates/gir-new-issue.md` | configurable issue template per account |
|
||||||
|
|
||||||
|
Once Phase 2–4 are done, the entire `gitea-issue-client/` Python tool
|
||||||
|
can be removed from the project root.
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea Plugin: backend.
|
||||||
|
*
|
||||||
|
* Replaces the standalone Python `gitea-issue-client` tool. MVP scope:
|
||||||
|
* accounts CRUD + connection test. Issue browsing/extraction comes once
|
||||||
|
* the connection layer is verified. See TODO.md for the deferred items.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/gitea/accounts → list accounts (no tokens)
|
||||||
|
* POST /api/gitea/accounts → create account
|
||||||
|
* PATCH /api/gitea/accounts/{id} → update (omit token to keep)
|
||||||
|
* DELETE /api/gitea/accounts/{id} → remove
|
||||||
|
* POST /api/gitea/accounts/{id}/test → verify token via /api/v1/user
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/GiteaClient.php';
|
||||||
|
|
||||||
|
// ── Schema migration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS gitea_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
default_repo TEXT DEFAULT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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) ────────
|
||||||
|
|
||||||
|
function giteaEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaEncrypt(string $plain): string {
|
||||||
|
$key = giteaEncKey();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaDecrypt(string $encoded): string {
|
||||||
|
$key = giteaEncKey();
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaGetAccount(int $id): ?array {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaOpenClient(array $row): GiteaClient {
|
||||||
|
return new GiteaClient($row['base_url'], giteaDecrypt($row['token']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ────────────────────────────────────────────
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
|
||||||
|
// ── Accounts CRUD ──
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/accounts' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/accounts' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$baseUrl = trim((string)($input['base_url'] ?? ''));
|
||||||
|
$token = (string)($input['token'] ?? '');
|
||||||
|
if ($name === '' || $baseUrl === '' || $token === '') {
|
||||||
|
giteaJson(['error' => 'name, base_url, and token are required'], 400);
|
||||||
|
}
|
||||||
|
// Normalize base URL: strip trailing slash, accept with/without scheme
|
||||||
|
if (!preg_match('#^https?://#i', $baseUrl)) {
|
||||||
|
$baseUrl = 'https://' . $baseUrl;
|
||||||
|
}
|
||||||
|
$baseUrl = rtrim($baseUrl, '/');
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO gitea_accounts (name, base_url, token)
|
||||||
|
VALUES (:n, :u, :t)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
'n' => $name,
|
||||||
|
'u' => $baseUrl,
|
||||||
|
't' => giteaEncrypt($token),
|
||||||
|
]);
|
||||||
|
giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = giteaGetAccount($id);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$fields = []; $params = ['id' => $id];
|
||||||
|
if (array_key_exists('name', $input)) {
|
||||||
|
$fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('base_url', $input)) {
|
||||||
|
$u = trim((string)$input['base_url']);
|
||||||
|
if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u;
|
||||||
|
$fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/');
|
||||||
|
}
|
||||||
|
if (!empty($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]);
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
|
giteaJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]);
|
||||||
|
giteaJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issues: list ──
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/issues' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
$state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open';
|
||||||
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$owner = trim((string)($_GET['owner'] ?? ''));
|
||||||
|
$scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = max(5, min(100, (int)($_GET['limit'] ?? 30)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$issues = $client->searchIssues([
|
||||||
|
'state' => $state,
|
||||||
|
'q' => $q !== '' ? $q : null,
|
||||||
|
'owner' => $owner !== '' ? $owner : null,
|
||||||
|
'type' => 'issues', // exclude PRs
|
||||||
|
'assigned' => $scope === 'assigned' ? true : null,
|
||||||
|
'created' => $scope === 'created' ? true : null,
|
||||||
|
'mentioned' => $scope === 'mentioned' ? true : null,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Map to a compact, UI-friendly shape
|
||||||
|
$mapped = array_map(function ($i) {
|
||||||
|
$repoFull = $i['repository']['full_name'] ?? null;
|
||||||
|
if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) {
|
||||||
|
$repoFull = $i['repository']['owner'] . '/' . $i['repository']['name'];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => $i['id'] ?? null,
|
||||||
|
'number' => $i['number'] ?? null,
|
||||||
|
'title' => $i['title'] ?? '',
|
||||||
|
'state' => $i['state'] ?? 'open',
|
||||||
|
'created_at' => $i['created_at'] ?? null,
|
||||||
|
'updated_at' => $i['updated_at'] ?? null,
|
||||||
|
'comments_count' => $i['comments'] ?? 0,
|
||||||
|
'html_url' => $i['html_url'] ?? null,
|
||||||
|
'repo' => $repoFull,
|
||||||
|
'author' => $i['user']['login'] ?? null,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $i['labels'] ?? []),
|
||||||
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []),
|
||||||
|
];
|
||||||
|
}, $issues);
|
||||||
|
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issues' => $mapped,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue detail (with comments) ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$owner = urldecode($m[2]);
|
||||||
|
$repo = urldecode($m[3]);
|
||||||
|
$index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$issue = $client->getIssue($owner, $repo, $index);
|
||||||
|
$comments = $client->listIssueComments($owner, $repo, $index);
|
||||||
|
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'id' => $issue['id'] ?? null,
|
||||||
|
'number' => $issue['number'] ?? $index,
|
||||||
|
'title' => $issue['title'] ?? '',
|
||||||
|
'body' => $issue['body'] ?? '',
|
||||||
|
'state' => $issue['state'] ?? 'open',
|
||||||
|
'created_at' => $issue['created_at'] ?? null,
|
||||||
|
'updated_at' => $issue['updated_at'] ?? null,
|
||||||
|
'closed_at' => $issue['closed_at'] ?? null,
|
||||||
|
'due_date' => $issue['due_date'] ?? null,
|
||||||
|
'html_url' => $issue['html_url'] ?? null,
|
||||||
|
'author' => $issue['user']['login'] ?? null,
|
||||||
|
'author_avatar' => $issue['user']['avatar_url'] ?? null,
|
||||||
|
'repo' => "$owner/$repo",
|
||||||
|
'owner' => $owner,
|
||||||
|
'repo_name' => $repo,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $issue['labels'] ?? []),
|
||||||
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []),
|
||||||
|
'milestone' => isset($issue['milestone']) && $issue['milestone'] ? [
|
||||||
|
'id' => $issue['milestone']['id'] ?? null,
|
||||||
|
'title' => $issue['milestone']['title'] ?? '',
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'comments' => array_map(fn($c) => [
|
||||||
|
'id' => $c['id'] ?? null,
|
||||||
|
'author' => $c['user']['login'] ?? null,
|
||||||
|
'body' => $c['body'] ?? '',
|
||||||
|
'created_at' => $c['created_at'] ?? null,
|
||||||
|
'updated_at' => $c['updated_at'] ?? null,
|
||||||
|
], $comments),
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue write operations ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
if ($body === '') giteaJson(['error' => 'comment body required'], 400);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$created = $client->createIssueComment($owner, $repo, $index, $body);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'comment' => [
|
||||||
|
'id' => $created['id'] ?? null,
|
||||||
|
'author' => $created['user']['login'] ?? null,
|
||||||
|
'body' => $created['body'] ?? '',
|
||||||
|
'created_at' => $created['created_at'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$changes = [];
|
||||||
|
foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) {
|
||||||
|
if (array_key_exists($k, $input)) $changes[$k] = $input[$k];
|
||||||
|
}
|
||||||
|
if (!$changes) giteaJson(['error' => 'no changes'], 400);
|
||||||
|
if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) {
|
||||||
|
giteaJson(['error' => 'invalid state'], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$updated = $client->editIssue($owner, $repo, $index, $changes);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'title' => $updated['title'] ?? '',
|
||||||
|
'body' => $updated['body'] ?? '',
|
||||||
|
'state' => $updated['state'] ?? 'open',
|
||||||
|
'updated_at' => $updated['updated_at'] ?? null,
|
||||||
|
'closed_at' => $updated['closed_at'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$labels = $input['labels'] ?? [];
|
||||||
|
if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'id' => $l['id'] ?? null,
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $resulting),
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachment proxy ──
|
||||||
|
// Gitea attachments live under /attachments/<uuid> on the gitea instance
|
||||||
|
// and require the auth token (or session cookie). The browser can't fetch
|
||||||
|
// them directly (CORS + no token), so hyprcrd proxies them.
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png"
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
// Build target URL on the gitea instance
|
||||||
|
$url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/');
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: token ' . giteaDecrypt($row['token']),
|
||||||
|
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
http_response_code(502);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
echo "Could not reach gitea: $err";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream';
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($code >= 400) {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
echo "Gitea returned HTTP $code";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip any default headers PHP might emit so we can replace them
|
||||||
|
header_remove('Pragma');
|
||||||
|
header_remove('Expires');
|
||||||
|
header('Content-Type: ' . $contentType);
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
header('Content-Length: ' . strlen($body));
|
||||||
|
echo $body;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create issue ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$title = trim((string)($input['title'] ?? ''));
|
||||||
|
if ($title === '') giteaJson(['error' => 'title is required'], 400);
|
||||||
|
$opts = ['title' => $title];
|
||||||
|
if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body'];
|
||||||
|
if (array_key_exists('labels', $input) && is_array($input['labels'])) {
|
||||||
|
$opts['labels'] = array_map('intval', $input['labels']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('assignees', $input) && is_array($input['assignees'])) {
|
||||||
|
$opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees'])));
|
||||||
|
}
|
||||||
|
if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone'];
|
||||||
|
if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$created = $client->createIssue($owner, $repo, $opts);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'id' => $created['id'] ?? null,
|
||||||
|
'number' => $created['number'] ?? null,
|
||||||
|
'title' => $created['title'] ?? '',
|
||||||
|
'state' => $created['state'] ?? 'open',
|
||||||
|
'html_url' => $created['html_url'] ?? null,
|
||||||
|
'owner' => $owner,
|
||||||
|
'repo_name' => $repo,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User repos (for the repo picker) ──
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/repos' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$repos = $client->listMyRepos(1, 100);
|
||||||
|
$mapped = array_map(fn($r) => [
|
||||||
|
'id' => $r['id'] ?? null,
|
||||||
|
'full_name' => $r['full_name'] ?? null,
|
||||||
|
'name' => $r['name'] ?? null,
|
||||||
|
'owner' => $r['owner']['login'] ?? null,
|
||||||
|
'description' => $r['description'] ?? '',
|
||||||
|
'private' => $r['private'] ?? false,
|
||||||
|
'archived' => $r['archived'] ?? false,
|
||||||
|
'permissions' => $r['permissions'] ?? null,
|
||||||
|
], $repos);
|
||||||
|
// Filter out archived repos and ones without push permission
|
||||||
|
$mapped = array_values(array_filter($mapped, function ($r) {
|
||||||
|
if ($r['archived']) return false;
|
||||||
|
$p = $r['permissions'] ?? null;
|
||||||
|
if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false;
|
||||||
|
return true; // unknown permissions = include
|
||||||
|
}));
|
||||||
|
giteaJson(['ok' => true, 'repos' => $mapped]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repo metadata ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
// Merge repo-level + org-level labels (org labels are inherited by
|
||||||
|
// repos in an org but don't appear in /repos/.../labels)
|
||||||
|
$repoLabels = $client->listRepoLabels($owner, $repo);
|
||||||
|
$orgLabels = [];
|
||||||
|
$warning = null;
|
||||||
|
try {
|
||||||
|
$orgLabels = $client->listOrgLabels($owner);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
if ($e->statusCode === 404) {
|
||||||
|
// Owner is a user, not an org — no org labels exist. silent.
|
||||||
|
} elseif ($e->statusCode === 403) {
|
||||||
|
// Token lacks read:organization scope — surface a warning
|
||||||
|
// but don't fail the whole request; repo labels still work.
|
||||||
|
$warning = 'Token lacks read:organization scope — org-level labels are not visible. Update your gitea token to include read:organization to see them.';
|
||||||
|
} else {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe by id (in case the same label appears in both)
|
||||||
|
$seen = [];
|
||||||
|
$merged = [];
|
||||||
|
foreach (array_merge($repoLabels, $orgLabels) as $l) {
|
||||||
|
$id = $l['id'] ?? null;
|
||||||
|
if ($id !== null && !isset($seen[$id])) {
|
||||||
|
$seen[$id] = true;
|
||||||
|
$merged[] = $l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$payload = [
|
||||||
|
'ok' => true,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'id' => $l['id'] ?? null,
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
'description' => $l['description'] ?? '',
|
||||||
|
], $merged),
|
||||||
|
];
|
||||||
|
if ($warning) $payload['warning'] = $warning;
|
||||||
|
giteaJson($payload);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/milestones$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$milestones = $client->listRepoMilestones($owner, $repo, 'open');
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'milestones' => array_map(fn($ms) => [
|
||||||
|
'id' => $ms['id'] ?? null,
|
||||||
|
'title' => $ms['title'] ?? '',
|
||||||
|
'description' => $ms['description'] ?? '',
|
||||||
|
'due_on' => $ms['due_on'] ?? null,
|
||||||
|
'state' => $ms['state'] ?? 'open',
|
||||||
|
], $milestones),
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/collaborators$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
// Use /assignees instead of /collaborators — it returns the full
|
||||||
|
// set of users that can be assigned (owner + org members +
|
||||||
|
// explicit collaborators), which is what we actually want.
|
||||||
|
$assignees = $client->listRepoAssignees($owner, $repo);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'collaborators' => array_map(fn($u) => [
|
||||||
|
'login' => $u['login'] ?? '',
|
||||||
|
'full_name' => $u['full_name'] ?? '',
|
||||||
|
'avatar_url' => $u['avatar_url'] ?? '',
|
||||||
|
], $assignees),
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = giteaGetAccount($id);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$user = $client->testConnection();
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'user' => [
|
||||||
|
'login' => $user['login'] ?? null,
|
||||||
|
'full_name' => $user['full_name'] ?? null,
|
||||||
|
'email' => $user['email'] ?? null,
|
||||||
|
'avatar_url' => $user['avatar_url'] ?? null,
|
||||||
|
],
|
||||||
|
'base_url' => $row['base_url'],
|
||||||
|
]);
|
||||||
|
} catch (GiteaApiException $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/* Gitea Plugin styles */
|
||||||
|
|
||||||
|
#gitea-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
|
||||||
|
|
||||||
|
/* Account list (in settings tab) */
|
||||||
|
.gitea-account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.gitea-account-row .gitea-account-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.gitea-account-row .gitea-account-name {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.gitea-account-row .gitea-account-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gitea-account-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue list rows */
|
||||||
|
.gitea-issue-row {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.gitea-issue-row:hover { background: var(--t-raised); }
|
||||||
|
.gitea-issue-row.active {
|
||||||
|
background: var(--t-raised);
|
||||||
|
border-left-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-repo {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.gitea-issue-row.state-closed .gitea-issue-title { color: var(--t-muted); }
|
||||||
|
.gitea-issue-row .gitea-issue-meta {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State badge (open/closed) */
|
||||||
|
.gitea-state-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.gitea-state-badge.open { background: #22c55e; color: #000; }
|
||||||
|
.gitea-state-badge.closed { background: #a855f7; color: #fff; }
|
||||||
|
|
||||||
|
/* Label chip (mirrors gitea label colors) */
|
||||||
|
.gitea-label {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment block */
|
||||||
|
.gitea-comment {
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.gitea-comment-header {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.gitea-comment-author { color: var(--t-bright); }
|
||||||
|
.gitea-comment-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue list — no text selection so dragging doesn't highlight rows */
|
||||||
|
#gitea-issue-list, #gitea-issue-list * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
#gitea-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||||
|
|
||||||
|
/* Label editor checkboxes */
|
||||||
|
.gitea-label-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.gitea-label-checkbox:hover { background: var(--t-raised); }
|
||||||
|
.gitea-label-checkbox input { accent-color: var(--t-accent); }
|
||||||
|
.gitea-label-checkbox .gitea-label { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Title input matches the h3 sizing so swap is seamless */
|
||||||
|
#gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; }
|
||||||
|
|
||||||
|
/* Custom repo autocomplete dropdown */
|
||||||
|
.gitea-repo-option {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-text);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.gitea-repo-option:last-child { border-bottom: none; }
|
||||||
|
.gitea-repo-option:hover,
|
||||||
|
.gitea-repo-option.active {
|
||||||
|
background: var(--t-raised);
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.gitea-repo-option .gitea-repo-tag {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Chrome/Safari autofill on the gitea token field */
|
||||||
|
#gitea-acc-token:-webkit-autofill,
|
||||||
|
#gitea-acc-token:-webkit-autofill:hover,
|
||||||
|
#gitea-acc-token:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: var(--t-bright);
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
caret-color: var(--t-bright);
|
||||||
|
transition: background-color 9999s ease-out, color 9999s ease-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
||||||
|
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
||||||
|
<div class="gitea-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||||
|
<h2 class="font-pixel text-xs text-t-accent">gitea</h2>
|
||||||
|
<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>
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||||
|
<div id="gitea-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||||
|
<div class="text-center max-w-md">
|
||||||
|
<i class="ph ph-git-pull-request text-t-accent text-6xl block mb-4"></i>
|
||||||
|
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to gitea</h3>
|
||||||
|
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||||
|
Read issues from one or more Gitea instances inside hyprcrd. Issues stay
|
||||||
|
on the server — only the ones you explicitly extract become cards.
|
||||||
|
To get started, add at least one account in settings.
|
||||||
|
</p>
|
||||||
|
<button id="gitea-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||||
|
<i class="ph ph-gear text-sm"></i>
|
||||||
|
<span class="font-pixel text-[11px]">Configure accounts</span>
|
||||||
|
</button>
|
||||||
|
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → gitea</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Issues reader ── -->
|
||||||
|
<div id="gitea-view-issues" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||||
|
<!-- Issue list -->
|
||||||
|
<div id="gitea-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:42%;">
|
||||||
|
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
||||||
|
<!-- Filters bar -->
|
||||||
|
<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" data-tip="Issue state">
|
||||||
|
<option value="open">open</option>
|
||||||
|
<option value="closed">closed</option>
|
||||||
|
<option value="all">all</option>
|
||||||
|
</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" data-tip="Filter by your involvement">
|
||||||
|
<option value="all">everyone</option>
|
||||||
|
<option value="assigned">assigned to me</option>
|
||||||
|
<option value="created">created by me</option>
|
||||||
|
<option value="mentioned">mentioning me</option>
|
||||||
|
</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">
|
||||||
|
<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" data-tip="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination toolbar -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-issue-list" class="divide-y divide-t-border"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="gitea-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||||
|
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Issue detail -->
|
||||||
|
<div id="gitea-detail-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||||
|
<div id="gitea-detail-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="ph ph-list-bullets text-3xl mb-2 block"></i>
|
||||||
|
<span class="cursor-blink">select an issue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ── Create issue form ── -->
|
||||||
|
<div id="gitea-create-form" class="hidden flex-col flex-1 min-h-0">
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
||||||
|
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="gitea-create-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block relative">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">repository <span id="gitea-create-repo-hint" class="normal-case text-t-muted text-[8px]"></span></span>
|
||||||
|
<input id="gitea-create-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">
|
||||||
|
<div id="gitea-create-repo-dropdown" class="hidden absolute left-0 right-0 top-full z-30 mt-0.5 max-h-56 overflow-y-auto bg-t-surface border border-t-accent shadow-lg"></div>
|
||||||
|
</label>
|
||||||
|
<div id="gitea-create-repo-error" class="hidden px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">title</span>
|
||||||
|
<input id="gitea-create-title" type="text" placeholder="short summary" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-sm focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">body (markdown)</span>
|
||||||
|
<textarea id="gitea-create-body" rows="14" placeholder="describe the issue..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="gitea-create-labels-warning" class="hidden mt-3 px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">labels (optional)</span>
|
||||||
|
<div id="gitea-create-labels" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">assignees (optional)</span>
|
||||||
|
<div id="gitea-create-assignees" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">milestone (optional)</span>
|
||||||
|
<select id="gitea-create-milestone" 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">
|
||||||
|
<option value="">— no milestone —</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-[9px] text-t-dim mt-1 italic">project: not supported by gitea api on issue create — set it manually on gitea after creation</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button id="gitea-create-submit" class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">Create issue</span></button>
|
||||||
|
<button id="gitea-create-cancel-2" class="flex items-center gap-1.5 px-3 py-1.5 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]">Cancel</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-create-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-detail" class="hidden flex flex-col flex-1 min-h-0">
|
||||||
|
<!-- Detail header -->
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<div class="flex items-start gap-3 mb-2">
|
||||||
|
<span id="gitea-detail-state" class="gitea-state-badge mt-1">open</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<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>
|
||||||
|
<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 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">
|
||||||
|
<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" data-tip="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] font-pixel text-t-dim mb-2">
|
||||||
|
<span id="gitea-detail-repo"></span>
|
||||||
|
·
|
||||||
|
<span class="text-t-muted">opened by</span> <span id="gitea-detail-author" class="text-t-text"></span>
|
||||||
|
<span id="gitea-detail-date-wrap"> · <span id="gitea-detail-date" class="text-t-text"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2 mb-2">
|
||||||
|
<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" data-tip="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<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-checkboxes" class="space-y-1 max-h-48 overflow-y-auto"></div>
|
||||||
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-t-border">
|
||||||
|
<button id="gitea-labels-save" class="flex items-center gap-1.5 px-2 py-0.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 id="gitea-labels-cancel" class="flex items-center gap-1.5 px-2 py-0.5 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]">Cancel</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Metadata row: assignees + milestone + due date with inline editor -->
|
||||||
|
<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>
|
||||||
|
<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 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>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-users text-xs"></i> assignees</span>
|
||||||
|
<div id="gitea-meta-assignees" class="border border-t-border bg-t-bg p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-flag text-xs"></i> milestone</span>
|
||||||
|
<select id="gitea-meta-milestone" 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">
|
||||||
|
<option value="">— no milestone —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<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" data-tip="Clear due date"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3 pt-2 border-t border-t-border">
|
||||||
|
<button id="gitea-meta-save" class="flex items-center gap-1.5 px-2 py-0.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 id="gitea-meta-cancel" class="flex items-center gap-1.5 px-2 py-0.5 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]">Cancel</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<button id="gitea-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="gitea-state-toggle" class="flex 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"><i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
|
||||||
|
<a id="gitea-open-link" href="#" target="_blank" rel="noopener" class="inline-flex 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"><i class="ph ph-arrow-square-out text-sm"></i><span class="font-pixel text-[10px]">Open on gitea</span></a>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-extract-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Body + comments scrollable -->
|
||||||
|
<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-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" data-tip="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<button id="gitea-body-save" class="flex items-center gap-1.5 px-3 py-1 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 id="gitea-body-cancel" class="flex items-center gap-1.5 px-3 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]">Cancel</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-detail-comments-section" class="mt-6 hidden">
|
||||||
|
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim border-b border-t-border pb-1 mb-3"><i class="ph ph-chat-circle text-xs"></i> comments (<span id="gitea-detail-comments-count">0</span>)</div>
|
||||||
|
<div id="gitea-detail-comments" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Comment composer (always visible when an issue is open) -->
|
||||||
|
<div class="mt-6 border-t border-t-border pt-4">
|
||||||
|
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-chat-text text-xs"></i> add comment</div>
|
||||||
|
<textarea id="gitea-comment-input" rows="3" placeholder="markdown supported..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<button id="gitea-comment-post" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">Post</span></button>
|
||||||
|
<span id="gitea-comment-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Gitea issue browser. Read issues and comments from one or more Gitea instances, extract them as cards. Multiple accounts supported, tokens encrypted with the vault key.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["gitea.js"],
|
||||||
|
"css": ["gitea.css"],
|
||||||
|
"html": ["gitea.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
// Logseq Import Plugin — backend route handler.
|
||||||
|
//
|
||||||
|
// Registers POST /api/import-logseq which accepts a Logseq vault ZIP upload and:
|
||||||
|
// 1. Copies vault assets into public/uploads/ with randomized filenames
|
||||||
|
// 2. Rewrites markdown image references to point at the new upload paths
|
||||||
|
// 3. Strips Logseq-specific bullet outline prefixes and property blocks
|
||||||
|
// 4. Creates a card per journal entry (date-titled) and per page
|
||||||
|
// 5. Places everything in an "import" bucket (created if missing)
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method): void {
|
||||||
|
if ($route !== '/api/import-logseq' || $method !== 'POST') return;
|
||||||
|
|
||||||
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'no file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'invalid zip']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$uploadDir = dirname(__DIR__, 2) . '/uploads';
|
||||||
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||||
|
|
||||||
|
// Find the vault root (the directory containing journals/ or pages/)
|
||||||
|
$prefix = '';
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
|
||||||
|
if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Copy assets → uploads/ with randomized filenames and build a rename map.
|
||||||
|
$assetMap = [];
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (!str_starts_with($name, $prefix . 'assets/')) continue;
|
||||||
|
if (str_ends_with($name, '/')) continue;
|
||||||
|
$content = $zip->getFromIndex($i);
|
||||||
|
if ($content === false) continue;
|
||||||
|
$origName = basename($name);
|
||||||
|
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
|
||||||
|
$newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
|
||||||
|
file_put_contents($uploadDir . '/' . $newName, $content);
|
||||||
|
$assetMap[$origName] = $newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: rewrite image references in markdown body to the new upload paths.
|
||||||
|
$rewriteImages = function ($body) use ($assetMap) {
|
||||||
|
// Logseq patterns: , , 
|
||||||
|
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function ($m) use ($assetMap) {
|
||||||
|
$file = basename($m[2]);
|
||||||
|
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
|
||||||
|
return $m[0];
|
||||||
|
}, $body);
|
||||||
|
// Also handle URL-encoded filenames in the assets path.
|
||||||
|
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function ($m) use ($assetMap) {
|
||||||
|
$file = urldecode(basename($m[2]));
|
||||||
|
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
|
||||||
|
return $m[0];
|
||||||
|
}, $body);
|
||||||
|
return $body;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: strip Logseq-specific syntax (property blocks + bullet outline prefixes).
|
||||||
|
$cleanBody = function ($body) {
|
||||||
|
$lines = explode("\n", $body);
|
||||||
|
$cleaned = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
// Skip Logseq properties (key:: value at start of line)
|
||||||
|
if (preg_match('/^[a-z_-]+::\s/', $line)) continue;
|
||||||
|
// Remove leading "- " bullet outline format (Logseq default)
|
||||||
|
$line = preg_replace('/^(\t*)- /', '$1', $line);
|
||||||
|
$cleaned[] = $line;
|
||||||
|
}
|
||||||
|
return implode("\n", $cleaned);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure the "import" bucket exists.
|
||||||
|
$importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn();
|
||||||
|
if (!$importBucket) {
|
||||||
|
$db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute();
|
||||||
|
$importBucket = $db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
|
||||||
|
// 2. Import journals → date-titled cards.
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (!str_starts_with($name, $prefix . 'journals/')) continue;
|
||||||
|
if (!str_ends_with($name, '.md')) continue;
|
||||||
|
$content = $zip->getFromIndex($i);
|
||||||
|
if ($content === false || !trim($content)) continue;
|
||||||
|
|
||||||
|
$basename = pathinfo(basename($name), PATHINFO_FILENAME);
|
||||||
|
// 2025_06_18 → 2025-06-18
|
||||||
|
$title = str_replace('_', '-', $basename);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue;
|
||||||
|
|
||||||
|
$body = $rewriteImages($cleanBody($content));
|
||||||
|
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
|
||||||
|
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)')
|
||||||
|
->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Import pages → cards with the page name as title.
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (!str_starts_with($name, $prefix . 'pages/')) continue;
|
||||||
|
if (!str_ends_with($name, '.md')) continue;
|
||||||
|
$content = $zip->getFromIndex($i);
|
||||||
|
if ($content === false || !trim($content)) continue;
|
||||||
|
|
||||||
|
$title = pathinfo(basename($name), PATHINFO_FILENAME);
|
||||||
|
// Skip Logseq internal pages.
|
||||||
|
if (in_array(strtolower($title), ['contents', 'favorites'])) continue;
|
||||||
|
|
||||||
|
$body = $rewriteImages($cleanBody($content));
|
||||||
|
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
|
||||||
|
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)')
|
||||||
|
->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]);
|
||||||
|
$imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Logseq Import Plugin — frontend.
|
||||||
|
//
|
||||||
|
// Injects a footer button + hidden file input, posts the selected ZIP to
|
||||||
|
// /api/import-logseq, and refreshes the board when the import completes.
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) throw new Error('[logseq-import] window.hyprcrd not found');
|
||||||
|
|
||||||
|
function injectFooterButton() {
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
if (!footer) return;
|
||||||
|
if (footer.querySelector('#logseq-import-btn')) return;
|
||||||
|
|
||||||
|
// Anchor before the settings button so the order matches the previous core layout.
|
||||||
|
const settingsBtn = footer.querySelector('#settings-btn');
|
||||||
|
const group = settingsBtn?.parentElement || footer.querySelector('.flex.items-center.gap-2');
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = 'logseq-import-file';
|
||||||
|
fileInput.accept = '.zip,application/zip';
|
||||||
|
fileInput.className = 'hidden';
|
||||||
|
group.insertBefore(fileInput, settingsBtn || null);
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'logseq-import-btn';
|
||||||
|
btn.className = 'flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||||
|
btn.innerHTML = '<i class="ph ph-graph text-sm"></i><span class="font-pixel text-[10px]">Logseq import</span>';
|
||||||
|
group.insertBefore(btn, settingsBtn || null);
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/import-logseq', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-CSRF-Token': hyprcrd.util.csrf() },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'logseq import',
|
||||||
|
message: `${data.imported} cards, ${data.assets} images imported`,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await hyprcrd.dialog.show({
|
||||||
|
title: 'logseq import failed',
|
||||||
|
message: String(err?.message || err) || 'unknown error',
|
||||||
|
danger: true,
|
||||||
|
okLabel: 'Ok',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectFooterButton);
|
||||||
|
} else {
|
||||||
|
injectFooterButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
hyprcrd.registerPlugin({
|
||||||
|
name: 'logseq-import',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Import a Logseq vault ZIP as dated journal cards and pages.',
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "logseq-import",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Import a Logseq vault ZIP — journals become dated cards, pages become cards with the page name as title, and assets are copied into uploads/ with rewritten references.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["logseq-import.js"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# oumta-dashboard plugin
|
||||||
|
|
||||||
|
Fullscreen view for the Oumta cluster dashboard (Go backend in `oumta-dashboard/`).
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **Oumta** button in the hyprcrd top bar (next to *Tasks*)
|
||||||
|
- `window._oumtaToggle()` from the console / other plugins
|
||||||
|
|
||||||
|
Close with **ESC** or the × button.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
- **Overview** — cluster state, workload counts, attention list
|
||||||
|
- **Workloads** — filterable table (name, state, replicas, cpu/mem, image, reason)
|
||||||
|
- **Timeline** — event stream; clicking a workload filters down to its own events
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Hardwired at the top of `oumta-dashboard.js` in the `CONFIG` block:
|
||||||
|
|
||||||
|
- **baseUrl** — where the Oumta Go backend is reachable (default `http://localhost:8085`).
|
||||||
|
- **authToken** — optional, sent as `Authorization: Bearer …` when non-empty.
|
||||||
|
- **pollIntervalSec** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
||||||
|
|
||||||
|
No UI settings panel: the dashboard only runs against one specific deployment, so exposing these as editable fields would be noise.
|
||||||
|
|
||||||
|
## State today
|
||||||
|
|
||||||
|
Everything is mocked. The live indicator in the top right shows `polling` once the overlay is open, but no real HTTP call fires yet. See [`TODO.md`](./TODO.md) for the integration plan.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"oumta-dashboard"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# oumta-dashboard — TODO
|
||||||
|
|
||||||
|
Everything in this file is work the plugin still needs to do before it's more than a mockup. Organised by phase.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Phase 0 (current)**: UI shell + mock data. Everything renders but no backend call is real.
|
||||||
|
- **Phase 1**: connect to real Go backend (`oumta-dashboard` repo) as endpoints become available.
|
||||||
|
- **Phase 2**: Keycloak bearer auth, PHP proxy decision, persisted settings.
|
||||||
|
- **Phase 3**: nice-to-haves (card auto-creation on incident, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — wire up the real API
|
||||||
|
|
||||||
|
All API calls live in `oumta-dashboard.js` → `api()`. Every case currently returns a `mock…` helper; swap them for `fetch` against the backend. The URL is constructed from `state.settings.baseUrl`.
|
||||||
|
|
||||||
|
### Endpoints to wire (listed in the order the UI consumes them)
|
||||||
|
|
||||||
|
| UI call | Backend endpoint (planned) | Status in backend | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api('/api/namespaces')` | `GET /api/namespaces` | **not yet implemented** | returns the namespace tabs shown at the top. See "Open backend questions" below. |
|
||||||
|
| `api('/api/cluster/overview')` | `GET /api/cluster/overview?namespace=…` | **exists (stub)** | current stub returns zeros. Also need `namespace` query param. |
|
||||||
|
| `api('/api/workloads')` | `GET /api/workloads?namespace=…&state=…&since=…` | not yet | filters: `state`, `since` (ISO), also maybe `search`. |
|
||||||
|
| `api('/api/workloads/{ns}/{name}/timeline')` | `GET /api/workloads/{ns}/{name}/timeline` | not yet | workload-detail timeline |
|
||||||
|
| `api('/api/timeline')` | `GET /api/timeline?namespace=…` | not yet | global timeline |
|
||||||
|
|
||||||
|
### Real `fetch` implementation sketch
|
||||||
|
|
||||||
|
In `api()` replace the mock dispatch with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const url = new URL(path, state.settings.baseUrl);
|
||||||
|
if (!url.searchParams.has('namespace') && state.activeNamespace)
|
||||||
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
|
const headers = state.settings.authToken
|
||||||
|
? { Authorization: 'Bearer ' + state.settings.authToken }
|
||||||
|
: {};
|
||||||
|
const res = await fetch(url, { ...opts, headers });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
There's also a commented-out sketch inside `api()` itself — delete the mock branches once real endpoints work.
|
||||||
|
|
||||||
|
### Contract alignment — [REAL] vs [PROPOSED] fields
|
||||||
|
|
||||||
|
Mocks in `oumta-dashboard.js` are labelled inline:
|
||||||
|
|
||||||
|
- **[REAL]** — field matches the Go struct exactly (from `internal/httpapi/router.go`). Must stay byte-for-byte aligned when flipping to real.
|
||||||
|
- **[PROPOSED]** — our guess based on prose in backend `docs/README.md`. Backend author may pick different names; UI has one place per field to update, and there's a commented-out adapter slot in `api()` if several diverge.
|
||||||
|
|
||||||
|
**Overview** (`GET /api/cluster/overview`) — entirely [REAL]:
|
||||||
|
```
|
||||||
|
clusterState (string: healthy | degraded | down | unknown)
|
||||||
|
workloadsUp (int)
|
||||||
|
workloadsDown (int)
|
||||||
|
updatedAtUtc (string, RFC3339)
|
||||||
|
```
|
||||||
|
Extras shown on the Overview page (incidents count, attention list) are **derived client-side** from the workloads response — no contract addition needed from backend.
|
||||||
|
|
||||||
|
**Workloads** (`GET /api/workloads`) — [PROPOSED], derived from `docs/README.md §2`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
workloadId // synthetic handle, format "deployment/{ns}/{name}"
|
||||||
|
name // ← from doc "Name"
|
||||||
|
namespace // ← "Namespace"
|
||||||
|
state // ← "Zustand" — vocab: healthy | degraded | unavailable
|
||||||
|
replicasReady // ← "Replikas (ready/…)"
|
||||||
|
replicasDesired // ← "Replikas (…/desired)"
|
||||||
|
cpu // ← "CPU-Auslastung"
|
||||||
|
memory // ← "Speicherverbrauch"
|
||||||
|
image // ← "Aktives Docker-Image"
|
||||||
|
lastReason // ← "Letzter bekannter Grund fuer Zustandswechsel"
|
||||||
|
lastTransitionAt // ← "inkl. letzter Statuswechsel-Zeit"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline** (`GET /api/timeline`, `GET /api/workloads/{ns}/{name}/timeline`) — [PROPOSED], derived from `docs/README.md §3`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
timestamp // RFC3339
|
||||||
|
severity // info | warning | error
|
||||||
|
workloadId // same handle as above
|
||||||
|
message // short human description
|
||||||
|
reason // cause code, e.g. CrashLoopBackOff, ImagePullBackOff
|
||||||
|
kind // deployment | incident | scale — optional UI hint, drop if absent
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Namespaces** (`GET /api/namespaces`) — [PROPOSED], new endpoint:
|
||||||
|
```
|
||||||
|
items: [{ id, label, namespace }]
|
||||||
|
```
|
||||||
|
`id` could equal `namespace` if backend treats them as the same. `label` is optional display text.
|
||||||
|
|
||||||
|
If/when real shapes differ, the fix path in priority order:
|
||||||
|
1. Rename in `mockXxx()` + renderer (one place each).
|
||||||
|
2. If multiple fields diverge on one endpoint, add a mapping step inside `api()` before `return`.
|
||||||
|
3. If the shape differs fundamentally, split the renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — auth, persistence, proxy decision
|
||||||
|
|
||||||
|
### Keycloak bearer flow
|
||||||
|
|
||||||
|
- Decision pending (see user note): probably SSO-wrapped. The plugin already sends `Authorization: Bearer <state.settings.authToken>` if a token is configured; the open question is **how the token arrives**.
|
||||||
|
- Options:
|
||||||
|
- **A.** User pastes a token into the settings tab (good for POC, not for prod).
|
||||||
|
- **B.** hyprcrd itself becomes a Keycloak client, acquires a token, exposes it via `window.hyprcrd.authToken` — plugin reads that. Requires a hyprcrd-core change.
|
||||||
|
- **C.** PHP proxy (see below) attaches the token server-side; the browser never sees it.
|
||||||
|
- **Recommendation**: go with **C** once we decide on the proxy. Until then **A** is fine for dev.
|
||||||
|
|
||||||
|
### PHP proxy in hyprcrd
|
||||||
|
|
||||||
|
- Today the plugin calls the Go backend **directly from the browser**. That means:
|
||||||
|
- The Go backend must return permissive CORS (it does: `Access-Control-Allow-Origin` is wide-open in dev).
|
||||||
|
- The user's browser must reach the backend (issue if the backend lives inside a private network).
|
||||||
|
- If we add a PHP proxy (file: `public/plugins/oumta-dashboard/backend.php`), we'd:
|
||||||
|
- Route `/api/oumta/*` → forward to `baseUrl + *`.
|
||||||
|
- Store `baseUrl` + `authToken` in hyprcrd's `settings` table instead of `localStorage`.
|
||||||
|
- Get CORS for free because the request originates from hyprcrd's own origin.
|
||||||
|
- **Trigger for adding the proxy**: when we get the Keycloak spec, or when users report "dashboard only works when I'm VPN'd".
|
||||||
|
|
||||||
|
### Settings persistence
|
||||||
|
|
||||||
|
- Currently `localStorage['oumta-dashboard-settings']` — fine for a single user in one browser.
|
||||||
|
- If we want per-tenant server-side settings, add a hyprcrd `settings` key like `oumta.base_url` and expose via a small PHP endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — realtime
|
||||||
|
|
||||||
|
- `startLive()` currently just starts polling (every `settings.pollIntervalSec`).
|
||||||
|
- SSE plan (see commented-out block in `startLive()`):
|
||||||
|
- Backend exposes `GET /api/events?namespace=…` as an `EventSource`-compatible stream.
|
||||||
|
- On `onopen` → `setLive('live')`, apply incoming events live (invalidate `state.data.overview` etc. or surgically patch).
|
||||||
|
- On `onerror` → fall back to polling, `setLive('polling')` with a hint.
|
||||||
|
- SSE event shape proposal (to negotiate with backend): `{ type: 'workload.state', workloadId, state, reason, timestamp }`, etc. Parking this until the backend is ready to talk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3+ — nice to haves (not in scope yet)
|
||||||
|
|
||||||
|
From the original questions catalog we parked:
|
||||||
|
- Timezone handling: UTC vs. local browser time (probably just use local + tooltip with UTC).
|
||||||
|
- Plugin settings in hyprcrd's settings-tab vs. per-user vs. shared across tenants.
|
||||||
|
- Auto-create a hyprcrd card when an incident is opened (tie the two systems together).
|
||||||
|
- Filter persistence in URL hash so links are shareable.
|
||||||
|
- Light/dark accent variants — currently inherits theme variables, should just work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open backend questions (to sync with the Go team)
|
||||||
|
|
||||||
|
1. **Namespace list endpoint** — UI needs an endpoint to populate the namespace tabs. The doc says "Namespace-Auswahl per Menü" and mentions env-var config on the backend. What's the endpoint? `GET /api/namespaces`? If not, how does the UI discover them?
|
||||||
|
2. **Response envelope** — the existing `overview` endpoint returns a flat object. Lists should probably be `{ items: [...] }` (consistent with our renderers) or `[]` (then we'll adapt). Pick one and stick with it across all list endpoints.
|
||||||
|
3. **Cluster state values** — our Overview card colour-codes on `healthy | degraded | down | unknown`. Backend currently returns `"unknown"` as a literal. Confirm the vocabulary.
|
||||||
|
4. **Workload state values** — we colour-code on `healthy | degraded | unavailable`. The doc uses these but the old .NET API also had `withRestarts` / `withWarnings` as summary counters. Only the three we use? Or more?
|
||||||
|
5. **Workload ID shape** — we use `deployment/{namespace}/{name}` as a synthetic id (so workloads can be linked from the timeline). Does the backend emit that exact form, or just `{namespace}/{name}`? We can adapt either way.
|
||||||
|
6. **Timeline pagination** — limit? cursor? since? The .NET version had all three; the Go doc mentions only filters. Enough for MVP?
|
||||||
|
7. **Realtime channel** — path + event format. We've got a plan above; needs the backend team's blessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal follow-ups
|
||||||
|
|
||||||
|
- `renderWorkloads` is called on every keystroke in the filter — fine up to ~1k rows, might need debouncing above that.
|
||||||
|
- `refresh()` tears down the scoped-workload timeline whenever it fires. For Phase 1 that's acceptable; once real data lands, consider NOT overwriting `state.data.timeline` when `state.data.scopeWorkload` is active.
|
||||||
|
- `bootstrap()` only runs on first show. If the user changes the API base URL in settings while the overlay is open, we re-start live mode but don't re-fetch namespaces. Add a "reset & reload" action if that matters.
|
||||||
|
- Namespace tab bar shows `namespace.label`; we currently fake it. Once real, consider showing the full k8s namespace if `label` and `namespace` differ.
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/* ── Namespace tabs ── */
|
||||||
|
.oumta-ns-tab {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.oumta-ns-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-ns-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* ── View tabs ── */
|
||||||
|
.oumta-view-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-view-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-view-tab.active { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.oumta-view.hidden { display: none; }
|
||||||
|
.oumta-view { padding: 20px 24px; }
|
||||||
|
|
||||||
|
/* ── Overview cards ── */
|
||||||
|
.oumta-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.oumta-card {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.oumta-card-wide { grid-column: span 4; }
|
||||||
|
.oumta-card-label {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-card-value {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 56px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.oumta-card-value-sm { font-size: 18px; font-weight: normal; line-height: 1.3; }
|
||||||
|
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-danger { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Fail state: whole card goes red; label, value, sub all flip to white
|
||||||
|
* so the box reads as a single alarm tile rather than a coloured number
|
||||||
|
* on dark chrome. Triggered from JS via the oumta-fail class. Uses the
|
||||||
|
* theme's --t-danger to stay consistent with the "unavailable" state
|
||||||
|
* labels and workload state colours elsewhere in the dashboard. */
|
||||||
|
.oumta-card.oumta-fail {
|
||||||
|
background: var(--t-danger);
|
||||||
|
border-color: var(--t-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.oumta-card.oumta-fail .oumta-card-label,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-value,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-sub {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
|
||||||
|
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
|
||||||
|
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
|
||||||
|
/* data-state="down" is handled by .oumta-fail (toggled from JS) so the
|
||||||
|
* cluster tile goes fully red + white like the other alarms. */
|
||||||
|
|
||||||
|
/* ── Attention list ── */
|
||||||
|
.oumta-section { margin-top: 8px; }
|
||||||
|
.oumta-section-title {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-attention-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.oumta-attention-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-attention-row:hover { border-color: var(--t-accent); }
|
||||||
|
.oumta-attention-name { flex: 1; color: var(--t-bright); font-size: 13px; }
|
||||||
|
.oumta-attention-ns { color: var(--t-dim); font-size: 11px; font-family: var(--font-content, monospace); }
|
||||||
|
.oumta-attention-reason { color: var(--t-muted); font-size: 11px; font-style: italic; }
|
||||||
|
.oumta-attention-state {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.oumta-attention-state[data-state="degraded"] { color: #fb923c; border-color: #fb923c; }
|
||||||
|
.oumta-attention-state[data-state="unavailable"] { color: var(--t-danger); border-color: var(--t-danger); }
|
||||||
|
|
||||||
|
.oumta-empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workload toolbar + table ── */
|
||||||
|
.oumta-toolbar {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.oumta-input {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.oumta-input:focus { border-color: var(--t-accent); }
|
||||||
|
#oumta-wl-search { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
.oumta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.oumta-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.oumta-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
||||||
|
.oumta-table tbody tr:hover { background: var(--t-bg); }
|
||||||
|
.oumta-table .wl-state-healthy { color: #4ade80; }
|
||||||
|
.oumta-table .wl-state-degraded { color: #fb923c; }
|
||||||
|
.oumta-table .wl-state-unavailable{ color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Timeline ── */
|
||||||
|
.oumta-timeline-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Workload filter pills: inline quick-pick. Horizontal-scrolls if the row
|
||||||
|
* runs out of space; an overflow dropdown is injected from JS when the
|
||||||
|
* pill count would blow out past the container. */
|
||||||
|
.oumta-tl-filters {
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.oumta-tl-pill {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.oumta-tl-pill:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.oumta-tl-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
|
||||||
|
|
||||||
|
.oumta-tl-more {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px); left: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button:hover { background: var(--t-bg); color: var(--t-bright); }
|
||||||
|
.oumta-tl-more-menu button.active { color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Timeline body: two-column layout (left = date badge + time + dot, right
|
||||||
|
* = event list). Clusters of events that fired within the same tolerance
|
||||||
|
* window get a single row with all sub-events listed in the right column.
|
||||||
|
*
|
||||||
|
* The decorative connector line is drawn ONCE on the body, not per entry —
|
||||||
|
* that's the only way to guarantee it's truly continuous (per-entry
|
||||||
|
* pseudo-elements collapse into gaps the moment an entry has a shorter
|
||||||
|
* left column than right). */
|
||||||
|
.oumta-timeline-body {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 79px; /* right edge of the 80px left column */
|
||||||
|
top: 24px; /* begin a bit below the first dot */
|
||||||
|
bottom: 24px; /* end a bit above the last dot */
|
||||||
|
width: 2px;
|
||||||
|
/* Soft fade at both ends so the line feels like it eases into the
|
||||||
|
* timeline rather than being clipped. The middle stays fully opaque. */
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
var(--t-border) 28px,
|
||||||
|
var(--t-border) calc(100% - 28px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
grid-template-rows: auto auto; /* optional date row on top, dot-row + events below */
|
||||||
|
gap: 0 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry > * { position: relative; z-index: 1; }
|
||||||
|
/* Time + dot share a horizontal row so clusters without a date badge
|
||||||
|
* show the time label LEFT of the dot, not stacked underneath. The row
|
||||||
|
* lives in grid row 2 so events in the right column (also row 2) stay
|
||||||
|
* vertically aligned with it — regardless of whether a date badge
|
||||||
|
* occupies row 1 above. */
|
||||||
|
.oumta-tl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: end; /* hug the connector line on the right */
|
||||||
|
align-self: start; /* don't stretch to the events column's full height — stay at the top */
|
||||||
|
}
|
||||||
|
.oumta-tl-date {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 4px 0 5px;
|
||||||
|
width: 44px;
|
||||||
|
background: var(--t-bg); /* solid, so the connector line behind doesn't strike through the text */
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: start; /* badge hugs the left edge of the column */
|
||||||
|
margin-bottom: 8px; /* spacing before the dot-row below */
|
||||||
|
}
|
||||||
|
.oumta-tl-date .day {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 18px; font-weight: 700;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.oumta-tl-date .mon {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.oumta-tl-time {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.oumta-tl-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* Dot sits at the right end of the time+dot row, which align-items:
|
||||||
|
* flex-end of the left column pushes to the right edge (x=80).
|
||||||
|
* Translate by half the dot width so its centre lines up exactly with
|
||||||
|
* the connector line (also at x=80). */
|
||||||
|
transform: translateX(5px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
|
||||||
|
background: var(--oumta-dot-color);
|
||||||
|
}
|
||||||
|
.oumta-tl-dot[data-sev="info"] { --oumta-dot-color: var(--t-accent); }
|
||||||
|
.oumta-tl-dot[data-sev="warning"] { --oumta-dot-color: #fb923c; }
|
||||||
|
.oumta-tl-dot[data-sev="error"] { --oumta-dot-color: var(--t-danger); }
|
||||||
|
.oumta-tl-dot[data-sev="recovered"] { --oumta-dot-color: #4ade80; }
|
||||||
|
.oumta-tl-dot[data-sev="image"] { --oumta-dot-color: #60a5fa; }
|
||||||
|
|
||||||
|
.oumta-tl-right {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 23px;
|
||||||
|
min-width: 0;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2; /* pinned to the same row as .oumta-tl-row so events line up with the dot */
|
||||||
|
}
|
||||||
|
.oumta-tl-event {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.oumta-tl-event-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity[data-sev="info"] { color: var(--t-accent); }
|
||||||
|
.oumta-tl-severity[data-sev="warning"] { color: #fb923c; }
|
||||||
|
.oumta-tl-severity[data-sev="error"] { color: var(--t-danger); }
|
||||||
|
.oumta-tl-severity[data-sev="recovered"] { color: #4ade80; }
|
||||||
|
.oumta-tl-severity[data-sev="image"] { color: #60a5fa; }
|
||||||
|
.oumta-tl-workload {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
.oumta-tl-workload:hover { color: var(--t-accent); }
|
||||||
|
.oumta-tl-reason { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-tl-message { font-size: 12px; color: var(--t-bright); line-height: 1.4; word-break: break-word; }
|
||||||
|
|
||||||
|
/* ── Live indicator ── */
|
||||||
|
.oumta-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--t-dim);
|
||||||
|
display: inline-block;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
||||||
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user