Compare commits

...
Author SHA1 Message Date
thandClaude Opus 4.6 618a787b36 tests: UI + API smoke tests for chat plugin
Covers the flyout lifecycle (footer button, toggle, header-back
visibility tracking thread/list view), the pick-view detour, DOM
structural invariants (peer strip inside thread view, form/input/send
present) and the three read-only chat endpoints. Send/receive requires
a real relay and is intentionally skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:43 +02:00
thandClaude Opus 4.6 81d9ce0e87 chat: peer-switch strip, back button moved to flyout header
Switching conversations used to require bouncing back to the list view
and picking from there. Now the thread view has a horizontal peer
strip at the top: one click hops to another conversation, active tab
is highlighted and auto-scrolls into view, unread peers show a dot.
Scrollbar is hidden — vertical wheel delta is redirected to horizontal
scroll so the user can slide the strip with the mouse wheel when it
overflows.

In-thread header is gone; the back button moved next to "chat" in the
flyout header and only shows while a thread is open. Peer name stays
visible via the active tab.

Bumps to 1.9.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:38 +02:00
thandClaude Opus 4.6 bfd5420981 relay: surface shared-bucket activity in compose sidebar
Relay UI already shows a tenant's inbox + sent mails. Add a parallel
view for collaborative bucket cards the logged-in tenant is a member
of, so activity on shared boards is visible without needing the hyprcrd
client open.

New `GET /api/collab-inbox` endpoint (session-auth only) decrypts
payloads with the inter-tenant key and returns title + preview + author
+ bucket metadata. UI restructures the compose view into editor + right
sidebar stacking inbox → shared → sent, and polls all three on the
existing 15s tick.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:03:18 +02:00
thandClaude Opus 4.6 fa3a6c2501 settings: readable font for design-panel slider values
9px pixel font on the design-panel slider value labels (Radius, Border,
Bar height, …) was hard to parse at that size. Switch to 11px regular
and widen the column slightly so two-digit values fit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:03:07 +02:00
thandClaude Opus 4.6 1a889ea435 tests: snapshot and reset view state around runs
Tests assume a normal board — no super-view, no bucket filter, manual
sort, no hides. When the runner fired with the app left in super-view
or with filters on, selectors like `.card[data-id]` missed the
`.superview-item` DOM and multi-select / due-date assertions read the
wrong initial values.

Snapshot the relevant bits of window.state before the run, force a
clean baseline, then restore the user's view afterwards so opening the
runner doesn't silently wipe their context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:58 +02:00
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +02:00
thandClaude Opus 4.6 b7f450e41f autosync toggle, configurable sync interval, 30-day session
- header Sync button now has a paired autosync toggle to its right —
  reads as one button with no inner seam (outer border only). Dot inside
  pulses once per actual sync (bursts on every backgroundSync /
  triggerSync / collab pollOnce via a new sync.pulse hook) so the user
  can see polls happen. Dot + data-autosync attribute persist the
  enabled state in localStorage; paused state stops both the
  CalDAV/relay background timer and the collab poll via a broadcast
  autosync.changed hook.
- hover polish: Sync regains a full accent border (the missing inner
  edge re-appears only on its hover); the toggle fills with solid
  accent as hover treatment so the pair never shows a half-outline.
- sync interval moved out of hardcode into a settings field under
  Settings → Relay: number input, 5s..3600s, default 15s (was 45s on
  caldav / 30s on collab). Changes broadcast via sync.interval.changed
  and restart both timers without reload.
- session cookie lifetime raised to 30 days with matching gc_maxlifetime
  and SameSite=Lax (was 0 / Strict — some users got kicked to login on
  every reload when gc cleaned up the /tmp/sess file or a cross-link
  return blocked the cookie).
- search button's kbd element was pushing the button ~3px taller than
  Sync; leading-none + tighter padding puts them on the same height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:57:24 +02:00
thandClaude Opus 4.6 6cd00f9660 diag: error_log sync-event POSTs to trace missing toasts
Temporary diagnostic scaffolding — B-side logs every POST to the relay
(status + body snippet), A-side already console.debugs the GET result
via sync_debug. Lets us narrow down where the sender-side toast gets
lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:24:35 +02:00
thandClaude Opus 4.6 4a668b7fc4 fixes: timeAgo utc parse, reload clears unread, sync-event debug
- detail banner timeAgo: SQLite timestamps are UTC without tz marker,
  JS was parsing them as local. Fixed by appending Z when absent —
  same pattern chat uses. "2h ago" for a 30s-old update is gone.
- banner Reload button now re-fires detail.opened so the collab-buckets
  plugin clears the red dot on the board card and calls mark-card-seen.
  Dismiss (×) still leaves the dot — that's the acknowledgement that
  nothing was actually read.
- instrument /api/relay/poll with a sync_debug field so the JS can
  console.debug what happened on the sync-events sub-request (status,
  count, error body). Helps diagnose "no toast" reports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:16:48 +02:00
thandClaude Opus 4.6 1966f8f4c0 sync-event toasts, image pull ordering, stale banner field diff
- replace chat-bot sync notifications with a dedicated relay_sync_events
  queue: POST/GET /api/sync-events, drained by /api/relay/poll and
  surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed,
  bot=1 rows purged on bootstrap, orange bot rendering dropped from
  relay UI. Chat thread stays clean.
- collab pull: fetch attachments BEFORE applying the body so the moment
  state.cards reflects the new reference the file is on disk. Fixes the
  "preview shows broken/missing image, editor shows it" race where the
  browser cached the 404 between attachment landing and user re-render.
- detail stale banner now compares title/body/color/tags in addition to
  updated_at — robust against second-granularity timestamp collisions
  and any other case where updated_at doesn't tick but content differs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:00:30 +02:00
thandClaude Opus 4.6 26f95ba079 collab per-card unread, detail stale banner, image sync race fix
- per-card unread dot on board (collab-buckets): backend tracks
  last_seen_at per card, poll exposes unread_cards, frontend decorates
  matching board cards; cleared on any open path via detail.opened hook
- detail.opened + cards.reloaded hooks on the hyprcrd event bus
- stale banner in detail view with manual Reload when the currently
  viewed card got updated by a peer — no content swap under the user
- collab image sync: push attachments BEFORE card (race fix) + pull-side
  self-heal scans every collab card for missing uploads each poll
- relay user deletion now purges chat_messages sent by deleted user so
  ghost threads stop appearing for their peers
- calendar header nav buttons styled as square bordered buttons that
  honor --ui-radius-button; title bar widened to 280px so font variants
  don't wrap
- .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:39:32 +02:00
thandClaude Opus 4.6 39e94b8015 oumta dashboard plugin (mockup)
Fullscreen cluster dashboard view consuming the Oumta Go backend.
All data currently mocked — see plugin TODO.md for integration plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:24:07 +02:00
thandClaude Opus 4.6 69ad2c7e71 fixes: password form wrap, collab-buckets csrf race
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:23:48 +02:00
thandClaude Opus 4.6 ae974314f9 sync electron version with main app
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:33:14 +02:00
thandClaude Opus 4.6 3b7c7cf5ac tim plugin, chat bot sync notifications, test fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:32:17 +02:00
thandClaude Opus 4.6 ca47052768 collab attachments, multi-day events, calendar improvements
- Collab buckets sync attachments alongside cards (images/PDFs/audio).
  New /api/collab-attachments endpoint on relay, encrypted at rest,
  bucket-scoped for members.
- CalDAV multi-day events via **Bis:** body field. Calendar renders
  event on every day in range with day counter (1/6, 2/6, ...).
- Calendar preserves month across close/reopen (only jumps to today
  on first open).
- Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] —
  dimmed brackets, fixed width so nothing wiggles.
- Editor: scratchpad uses custom confirm, autobackup-draft dialog
  replaced with custom styled dialog.
- Scanlines theme feature removed (performance issue).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:22:02 +02:00
thandClaude Opus 4.6 002562b1d3 fixes: tag assign stale body, board placeholder, plugin pending, bucket activate
- Context menu tag toggle reads fresh body from state.cards — was
  overwriting with stale snapshot, dropping existing tags (e.g. #done)
- Board empty-state centered with flex (vertical + horizontal)
- Plugin settings: batch toggles via Apply button, single reload
  instead of one reload per toggle
- New bucket creation activates it directly on the board

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:13:46 +02:00
thandClaude Opus 4.6 3cca14a3c6 fixes: collab move, caldav abort, draft recovery, autobackup, test stabilization
- Collab: moving a shared card to a local bucket no longer deletes it
  (clear relay_uid before tombstone echo returns)
- CalDAV: abort sync on remote fetch failure instead of treating all
  local cards as remotely-deleted
- Editor: autobackup drafts to localStorage with restore prompt on
  re-open (skipped during tests)
- Cards POST: persist scratchpad and due_date (was dropped on create)
- Disable spellcheck/autocorrect on editor inputs
- Test runner: purge leftover __prefix__ cards/buckets + editor drafts
  before every run (cleans up from failed previous runs)
- Stabilize mark-done, group-delete, undo-body tests with poll loops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:01:07 +02:00
thandClaude Opus 4.6 7e2b130434 fixes: scratchpad, zen ESC, chat time, tooltip safety, collab sync race
- Scratchpad: card-scoped private notes in editor (toggle vs preview),
  smaller font, cleared via button, stored in cards.scratchpad
- Zen mode: ESC exits zen before closing detail, Z toggles zen
- Chat: always show absolute time (hh:mm, yesterday, or date+time)
- Backspace/Delete also deletes focused card (not just selected)
- Selected + focused cards use brand accent (not accent2)
- Tooltip: guard against non-element targets
- Collab sync: skip poll while editor is open, 2s push debounce
- Rrule test: only check geburtstag tag, not title format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:45:31 +02:00
thandClaude Opus 4.6 2169c1de1b terminal plugin, card edit history, bump script
Terminal: in-app command line with 22 commands (Shift+T toggle).
Navigation, CRUD, search, view toggles, tab completion, history.

Card history: card_history table tracks field changes on every PATCH.
GET /api/cards/{id}/history endpoint. Terminal `history` command.

bump.sh: standalone version bump for config.php, electron/package.json,
website footer. dist.sh no longer bumps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:10:43 +02:00
thandClaude Opus 4.6 b9bd294c90 fix: image resize preview, todo checkbox alignment, modal overflow clip
- Image resize respects width attr in preview (no more snap-back)
- Single-task checkbox aligned with flex center instead of translateY hack
- Modal/detail containers clip card-bar at rounded corners
- Right-click on image no longer triggers resize (left-button only)
- Split dist.sh and bump.sh into separate scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:17:24 +02:00
thandClaude Opus 4.6 6c0ca4373b relay: file attachments, outbox, CC-self, chat view, mobile layout
Compose: attach files (encrypted, hex-named, linked via relay_uid),
CC-self checkbox tags copy with recipient name. Outbox section shows
sent messages with delete. Chat view: session-based threads/history/
send endpoints, split layout with sidebar, read receipts, 5s poll.
Mobile: threads as horizontal scroll bar above messages. Fix chat
input pushed offscreen by adding min-height:0 on flex container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:48:23 +02:00
thandClaude Opus 4.6 9f85e484c4 example-content plugin, project website with screenshot
Example-content: footer button creates 20 realistic demo cards in a
"Project Alpha" bucket for screenshots and first-time exploration.

Website: full-page sections with large watermark labels, screenshot
hero with typing animation, email/spam section, Gitea detail,
relay encryption flow, keyboard keycaps, deploy options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:54:43 +02:00
thandClaude Opus 4.6 b9345a410f watch plugin: analog clock overlay with SVG watchface
Simple watchface with second-hand wobble animation, 5-min idle
auto-show, W key toggle, light/dark theme colors, backdrop blur
from theme settings. Grace period prevents instant dismiss.

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

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

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

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

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

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

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

No files matched your search

+4
View File
@@ -11,3 +11,7 @@
electron/node_modules
data/hyprcrd.db-shm
data/hyprcrd.db-wal
/oumta-dashboard/
/oumta-dashboard-ui/
+53 -8
View File
@@ -110,10 +110,23 @@ hyprcrd/
│ │ └── 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)
├── relay/ # Relay service (mobile inbox + card sharing)
│ ├── index.php # Single-file API + Mobile UI
├── electron/ # Electron desktop app wrapper
│ ├── 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
│ ├── Dockerfile
│ └── dist.sh # Build relay distribution ZIP
@@ -128,7 +141,7 @@ hyprcrd/
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
- **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
- **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault
- **Drag & drop** card reordering and grouping
@@ -141,7 +154,14 @@ hyprcrd/
- **Notification banner** for upcoming events, birthdays, overdue tasks
- **Board split view** with side-by-side preview
- **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
@@ -150,7 +170,13 @@ hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plu
### 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.
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `?test` is in the URL or `Ctrl+Shift+T` is pressed, so production users pay no cost for it.
- **`chat/`** — Encrypted chat between relay tenants. Footer flyout with delivery + read receipts.
- **`collab-buckets/`** — Collaborative buckets shared between relay tenants with exclusive card locking and E2E encrypted sync.
- **`email/`** — Email overlay (toggled with `M` key).
- **`gitea/`** — Gitea integration: create issues from cards, link to repos.
- **`slideshow/`** — Slideshow presentation mode for cards with `slide-image` blocks.
- **`logseq-import/`** — Import Logseq markdown journals and pages.
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `Ctrl+Shift+Alt+T` is pressed, so production users pay no cost for it.
### Plugin layout
@@ -417,9 +443,8 @@ Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:808
master key lives outside the DB in a chmod-600 file
- **24-hour ephemeral**: messages and attachments auto-delete after a
day. Cards extracted in hyprcrd are the long-term store
- **Future: end-to-end encrypted chat** between tenants is planned and
will require per-pair key exchange — see `relay/README.md` for the
roadmap
- **Encrypted chat**: tenant-to-tenant chat with delivery + read receipts
(via chat plugin). Per-pair E2E key exchange is on the roadmap
### Relay Security
@@ -451,6 +476,26 @@ your next visit. You'll need to:
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
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
After changing Tailwind utility classes in HTML or JS files:
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Bump version in config.php + electron/package.json
set -e
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
echo "Usage: ./bump.sh [--patch|--minor|--major]"
echo ""
echo " --patch bump patch version (default) 1.3.5 → 1.3.6"
echo " --minor bump minor version 1.3.6 → 1.4.0"
echo " --major bump major version 1.4.0 → 2.0.0"
exit 0
fi
cd "$(dirname "$0")"
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case "${1:---patch}" in
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
*) PATCH=$((PATCH + 1)) ;;
esac
VERSION="${MAJOR}.${MINOR}.${PATCH}"
# config.php
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
# electron/package.json
if [ -f electron/package.json ]; then
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${VERSION}\"/" electron/package.json
fi
# website footer
if [ -f website/index.html ]; then
sed -i "s/v${CURRENT}/v${VERSION}/g" website/index.html
fi
echo "${CURRENT} → ${VERSION}"
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.1.0');
define('APP_VERSION', '1.9.3');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+2 -1
View File
@@ -1,5 +1,6 @@
{
"disabled": [
"logseq-import"
"logseq-import",
"example-content"
]
}
+7 -21
View File
@@ -1,35 +1,21 @@
#!/bin/bash
# Build hyprcrd distribution ZIP with version bump + minification
# Build hyprcrd distribution ZIP (no version bump — use ./bump.sh first)
set -e
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
echo "Usage: ./dist.sh [--patch|--minor|--major]"
echo "Usage: ./dist.sh"
echo ""
echo " --patch bump patch version (default) 0.1.1 → 0.1.2"
echo " --minor bump minor version 0.1.2 → 0.2.0"
echo " --major bump major version 0.2.0 → 1.0.0"
echo "Builds Tailwind CSS, minifies JS/CSS, and creates"
echo "hyprcrd-{version}.zip ready for deployment."
echo ""
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php,"
echo "and creates hyprcrd-{version}.zip ready for deployment."
echo "To bump the version first: ./bump.sh [--patch|--minor|--major]"
exit 0
fi
cd "$(dirname "$0")"
# Read current version
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
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}"
VERSION=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
echo "Building v${VERSION}"
DIST="hyprcrd-${VERSION}.zip"
STAGE="/tmp/hyprcrd-stage-$$"
+7 -2
View File
@@ -10,8 +10,13 @@ per platform so users don't need PHP installed.
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
4. On quit, the PHP process is killed cleanly
Data is stored in `data/hyprcrd.db` (SQLite), relative to the project root
(dev) or inside the app's `resources/` directory (packaged).
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
+3
View File
@@ -118,6 +118,9 @@ async function startPhpServer() {
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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.1.0",
"version": "1.9.3",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+9 -9
View File
@@ -6,17 +6,17 @@ installation.
## Download
Get static builds from https://github.com/crazywhalecc/static-php-cli/releases
(look for `php-8.3-*-cli` assets). Required extensions: pdo_sqlite, zip, session.
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 universal binary (arm64 + x86_64)
win32/php.exe — Windows PE binary
darwin/php — macOS aarch64 binary
win32/php.exe — Windows PE binary (not available from static-php-cli; use windows.php.net)
## Quick fetch (Linux example)
## Quick fetch
curl -L -o linux/php \
"https://dl.static-php.dev/static-php-cli/common/php-8.3.23-cli-linux-x86_64.tar.gz" \
&& tar -xzf linux/php -C linux/ && rm linux/php.tar.gz
Use the included script from the `electron/` directory:
Make sure the binary is executable: `chmod +x linux/php`
./fetch-php.sh
This auto-detects the latest PHP 8.3 version and downloads Linux + macOS binaries.
+166 -21
View File
@@ -1,7 +1,34 @@
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
/* Tooltip system */
#tooltip {
display: none;
position: fixed;
z-index: 9999;
max-width: 280px;
padding: 4px 8px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
line-height: 1.5;
color: var(--t-bright);
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
pointer-events: none;
white-space: nowrap;
}
#tooltip.tooltip-help {
white-space: normal;
padding: 8px 12px;
font-size: 10px;
max-width: 320px;
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
color: var(--t-muted);
border-left: 3px solid var(--t-accent);
}
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
.brand { font-family: 'Silkscreen', cursive !important; }
body.no-scanlines::after { display: none !important; }
#dialog-msg { white-space: pre-line; word-break: break-word; }
/* ── User-customizable design tokens ── */
@@ -28,7 +55,7 @@ body.no-scanlines::after { display: none !important; }
#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: 1250px) {
@media (min-width: 1450px) {
#footer-count { position: absolute; left: 50%; translate: -50% 0; }
}
html { min-width: 1150px; }
@@ -91,9 +118,12 @@ select[class*="border-t-accent"] {
#tasks > div,
#calendar > div,
#gallery > div,
#dialog > div {
#dialog > div,
#detail > div,
#modal > div {
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
overflow: hidden;
}
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
@@ -141,7 +171,6 @@ body.anim-slow .card-spinner {
position: relative; padding: 14px; overflow: hidden;
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
}
.theme-preview .tp-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px); }
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
.theme-preview .tp-card {
flex: 1; min-width: 0;
@@ -196,7 +225,7 @@ body.anim-slow .card-spinner {
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
.design-row > select { width: 110px; }
.design-row > .design-val { font-family: var(--t-font-pixel); font-size: 9px; color: var(--t-dim); width: 36px; text-align: right; flex-shrink: 0; }
.design-row > .design-val { font-size: 11px; color: var(--t-muted); width: 44px; text-align: right; flex-shrink: 0; }
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
.design-section-label:first-child { margin-top: 0; }
#design-advanced > summary { list-style: none; }
@@ -268,16 +297,6 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
*::-webkit-scrollbar-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 */
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
@@ -463,9 +482,9 @@ body::after {
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
.card-focused {
outline: 2px solid var(--t-accent2) !important;
outline: 2px solid var(--t-accent) !important;
outline-offset: -2px;
box-shadow: 0 0 0 1px var(--t-accent2), 0 0 16px -4px color-mix(in srgb, var(--t-accent2) 40%, transparent) !important;
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 {
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
@@ -821,6 +840,24 @@ body.anim-slow .board-card.card-pulse-local {
/* Super-bucket dropdown popover. Positioned via inline styles by
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);
@@ -895,7 +932,7 @@ body.anim-slow .board-card.card-pulse-local {
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
/* 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); }
/* Editor */
@@ -1029,7 +1066,7 @@ body.anim-slow .board-card.card-pulse-local {
margin: 0 !important;
margin-left: -1.5em !important;
display: flex !important;
align-items: flex-start;
align-items: center;
gap: 10px;
min-height: 28px;
padding: 5px 0;
@@ -1040,7 +1077,6 @@ body.anim-slow .board-card.card-pulse-local {
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
.todo-cb {
width: 18px; height: 18px; min-width: 18px;
transform: translateY(-9px);
border: 2px solid var(--t-border); border-radius: 3px;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
@@ -1093,9 +1129,13 @@ body.anim-slow .board-card.card-pulse-local {
#board-preview-body.prose img,
#board-preview-body .prose img {
max-width: 100%;
width: auto;
height: auto;
}
#editor-preview .prose img:not([width]),
#board-preview-body.prose img:not([width]),
#board-preview-body .prose img:not([width]) {
width: auto;
}
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
.prose 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; }
@@ -1154,3 +1194,108 @@ body.anim-slow .board-card.card-pulse-local {
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
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 -1
View File
@@ -1,2 +1,2 @@
/*! 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
+177 -64
View File
@@ -17,10 +17,20 @@ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
}
// ── Session ──
//
// 30-day persistence: single-user local-first app, no reason to log the
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
// server-side GC are set to the same duration so the session survives
// reboots AND page reloads, up to a month of inactivity.
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
]);
session_start();
@@ -250,12 +260,14 @@ if (str_starts_with($route, '/api/')) {
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0;
$body = $input['body'] ?? '';
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null),
'position' => validInt($pos),
'tags' => extractTags($body),
'due_date' => $input['due_date'] ?? null,
'scratchpad' => $input['scratchpad'] ?? null,
]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
exit;
@@ -293,10 +305,32 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
$stmt->execute(['id' => $m[1]]);
echo json_encode($stmt->fetchAll()); exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
// Snapshot changed fields before update
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
$changedTracked = array_intersect($tracked, array_keys($input));
if ($changedTracked) {
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
if ($old) {
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
foreach ($changedTracked as $f) {
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
if ((string)$oldVal !== (string)$newVal) {
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
}
}
}
}
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
}
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
@@ -868,6 +902,32 @@ if (str_starts_with($route, '/api/')) {
}
$pulled++;
// Fire a sync-event notification to the sender so their hyprcrd
// surfaces a toast. Best-effort — an older relay may not have
// /api/sync-events, and we don't want to fail the whole poll.
$senderName = $msg['sender_username'] ?? '';
if ($senderName && $senderName !== $relayUser) {
$eventBody = $relayUser . ' synced: ' . $title;
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($bch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $relayUser,
'X-Relay-Key: ' . $relayKey,
],
CURLOPT_TIMEOUT => 5,
]);
$bRes = curl_exec($bch);
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
curl_close($bch);
@error_log('[hyprcrd] sync-event POST → ' . $senderName
. ' status=' . $bStat
. ' body=' . substr((string)$bRes, 0, 120));
}
// Download attachments
if ($relayUid) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
@@ -900,14 +960,45 @@ if (str_starts_with($route, '/api/')) {
}
if (strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($decrypted !== false) file_put_contents($dest, $decrypted);
if ($decrypted !== false) {
$fileBytes = base64_decode($decrypted);
if ($fileBytes !== false) $decrypted = $fileBytes;
file_put_contents($dest, $decrypted);
}
}
}
}
}
}
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
// Drain pending sync-event notifications so the JS caller can toast
// them. GET /api/sync-events on the relay auto-marks fetched events.
// Best-effort — older relays won't have the endpoint.
$syncEvents = [];
$syncDebug = ['requested' => true];
$sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($sch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 5,
]);
$sResp = curl_exec($sch);
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
curl_close($sch);
$syncDebug['status'] = $sStatus;
if ($sStatus === 200) {
$decoded = json_decode($sResp, true);
if (is_array($decoded) && isset($decoded['events'])) {
$syncEvents = $decoded['events'];
$syncDebug['count'] = count($syncEvents);
} else {
$syncDebug['parse_error'] = substr((string)$sResp, 0, 200);
}
} else {
$syncDebug['body'] = substr((string)$sResp, 0, 200);
}
echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents, 'sync_debug' => $syncDebug]); exit;
}
// List relay tenants (for the recipient picker)
@@ -1130,7 +1221,6 @@ if (str_starts_with($route, '/api/')) {
// color theme
theme: 'slate',
// typography
scanlines: '1',
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
// geometry
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
@@ -1144,7 +1234,6 @@ if (str_starts_with($route, '/api/')) {
window.builtinPresets = {
Terminal: { ...window.designDefaults },
Modern: { theme: 'paper',
scanlines: '0',
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
borderCard: 1, borderStyle: 'solid', barHeight: 3,
@@ -1182,7 +1271,6 @@ if (str_starts_with($route, '/api/')) {
root.setProperty('--ui-lift-card', d.liftCard + 'px');
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
// body classes
document.body.classList.toggle('no-scanlines', String(d.scanlines) !== '1');
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
@@ -1194,8 +1282,6 @@ if (str_starts_with($route, '/api/')) {
// Migrate legacy individual keys (only if no JSON blob exists yet)
if (!Object.keys(saved).length) {
const legacy = {};
const sl = localStorage.getItem('hyprcrd-scanlines');
if (sl !== null) legacy.scanlines = sl;
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
@@ -1225,11 +1311,16 @@ if (str_starts_with($route, '/api/')) {
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
</button>
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="sync now">
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
</button>
<div class="header-sync-group flex items-center">
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="sync now">
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
</button>
<button id="header-autosync" class="flex items-center justify-center px-2 py-1.5 text-xs border border-t-border transition-colors" data-tip="auto-sync">
<span id="header-autosync-dot" class="header-autosync-dot"></span>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
@@ -1250,25 +1341,25 @@ if (str_starts_with($route, '/api/')) {
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
<div class="flex items-center gap-2">
<i class="ph ph-lightning text-t-accent text-sm"></i>
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
</div>
</div>
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
<button id="board-split-toggle" class="toolbar-toggle" title="split preview">
<button id="board-split-toggle" class="toolbar-toggle" data-help="Split preview\nShows a side panel with card preview,\nrelated cards, and backlinks.">
<i class="ph ph-split-horizontal text-sm"></i>
</button>
<button id="superview-toggle" class="toolbar-toggle" title="compact list view">
<button id="superview-toggle" class="toolbar-toggle" data-help="Superview\nCompact multi-column list with\nhover cards. Great for overviews.">
<i class="ph ph-list text-sm"></i>
</button>
<button id="hide-done" class="toolbar-toggle" title="toggle done">
<button id="hide-done" class="toolbar-toggle" data-tip="toggle done">
<i class="ph ph-check-circle text-sm"></i>
</button>
<button id="hide-termine" class="toolbar-toggle" title="toggle appointments">
<button id="hide-termine" class="toolbar-toggle" data-tip="toggle appointments">
<i class="ph ph-calendar-x text-sm"></i>
</button>
<button id="hide-tagged" class="toolbar-toggle" title="toggle tagged cards">
<button id="hide-tagged" class="toolbar-toggle" data-tip="toggle tagged cards">
<i class="ph ph-hash text-sm"></i>
</button>
<button id="sort-toggle" class="toolbar-toggle">
@@ -1621,7 +1712,6 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<div id="theme-preview" class="theme-preview">
<div class="tp-scan" id="theme-preview-scan"></div>
<div class="tp-row">
<div class="tp-card">
<div class="tp-card-bar"></div>
@@ -1650,7 +1740,7 @@ if (str_starts_with($route, '/api/')) {
<div class="design-section-label">presets</div>
<div class="design-row">
<select id="design-preset-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer flex-1"></select>
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" title="Delete user preset"><i class="ph ph-trash text-xs"></i></button>
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" data-tip="delete user preset"><i class="ph ph-trash text-xs"></i></button>
</div>
<div class="design-row">
<input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none">
@@ -1668,7 +1758,6 @@ if (str_starts_with($route, '/api/')) {
</summary>
<div class="mt-2">
<div class="design-section-label">typography</div>
<div class="toggle-row"><span class="toggle-label">Scan lines</span><div class="toggle-switch" id="setting-scanlines"></div></div>
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
@@ -1693,7 +1782,7 @@ if (str_starts_with($route, '/api/')) {
<div class="design-section-label">effects</div>
<div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div>
<div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div>
<div class="design-row"><label>Animation</label><select id="setting-anim-speed" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div>
@@ -1741,6 +1830,15 @@ if (str_starts_with($route, '/api/')) {
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
</div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
<div class="border-t border-t-border pt-4 mt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted" for="setting-sync-interval">Poll every</label>
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</span>
</div>
</div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
@@ -1860,12 +1958,13 @@ if (str_starts_with($route, '/api/')) {
<div id="settings-panel-plugins" class="hidden space-y-4">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.</p>
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggle multiple plugins and apply them together — the page reloads once when you click Apply.</p>
<div id="plugins-list" class="space-y-2"></div>
<div id="plugins-reload-notice" class="hidden mt-3 flex items-center gap-2 px-3 py-2 border border-t-accent bg-t-accent/10 text-xs text-t-accent">
<i class="ph ph-warning text-sm"></i>
<span class="flex-1 font-pixel text-[10px]">reload required to apply changes</span>
<button id="plugins-reload-btn" class="font-pixel text-[10px] underline hover:text-t-bright">Reload now</button>
<div id="plugins-apply-bar" class="hidden mt-3 flex items-center gap-3 px-3 py-2 border border-t-accent bg-t-accent/10">
<i class="ph ph-warning text-sm text-t-accent"></i>
<span id="plugins-apply-count" class="flex-1 font-pixel text-[10px] text-t-accent"></span>
<button id="plugins-discard-btn" class="font-pixel text-[10px] text-t-muted hover:text-t-danger transition-colors">discard</button>
<button id="plugins-apply-btn" class="font-pixel text-[10px] px-3 py-1 bg-t-accent text-t-bg hover:brightness-110 transition-all">Apply</button>
</div>
</div>
</div>
@@ -1881,6 +1980,7 @@ if (str_starts_with($route, '/api/')) {
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
<form id="settings-pw-form" onsubmit="return false;">
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
@@ -1894,9 +1994,10 @@ if (str_starts_with($route, '/api/')) {
<input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
</div>
<div class="flex items-center gap-3">
<button id="settings-pw-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button>
<button id="settings-pw-save" type="button" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button>
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
</form>
</div>
</div>
</div>
@@ -1935,7 +2036,7 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace">
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open.">
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
</button>
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
@@ -2018,14 +2119,14 @@ if (str_starts_with($route, '/api/')) {
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Del</span>
</button>
<button id="detail-done" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="mark done">
<button id="detail-done" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="mark done">
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">Done</span>
</button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode">
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
<i class="ph ph-corners-out text-sm"></i>
</button>
<div class="flex-1"></div>
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="split view">
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
</button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
@@ -2064,14 +2165,16 @@ if (str_starts_with($route, '/api/')) {
<!-- Calendar -->
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<h2 id="cal-title" class="font-pixel text-xs text-t-accent min-w-[120px] text-center"></h2>
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<button id="cal-prev" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="previous"><i class="ph ph-caret-left text-base"></i></button>
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:280px;flex-shrink:0;"></h2>
<button id="cal-next" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="next"><i class="ph ph-caret-right text-base"></i></button>
</div>
<div class="flex-1"></div>
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
@@ -2169,11 +2272,11 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="set due date">
<input type="text" id="inp-title" placeholder="> title..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="set due date">
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
</button>
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="mark done">
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="mark done">
<i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">Done</span>
</button>
<input type="hidden" id="inp-due-value" value="">
@@ -2182,33 +2285,35 @@ if (str_starts_with($route, '/api/')) {
<input type="hidden" id="inp-color" value="#38bdf8">
</div>
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
<button data-md="heading" title="Heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
<button data-md="bold" title="Bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
<button data-md="italic" title="Italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
<button data-md="strikethrough" title="Strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
<button data-md="heading" data-tip="heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
<button data-md="bold" data-tip="bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
<button data-md="italic" data-tip="italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
<button data-md="strikethrough" data-tip="strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="code" title="Code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
<button data-md="codeblock" title="Code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<button data-md="code" data-tip="code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
<button data-md="codeblock" data-tip="code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="ul" title="List" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" title="Tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" title="Numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" title="Quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" title="Todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
<button data-md="ul" data-tip="list" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" data-tip="tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" data-tip="numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" data-tip="quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" data-tip="todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="link" title="Link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<button data-md="link" data-tip="link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<button data-md="cardlink" data-tip="card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
<button data-md="hr" data-tip="divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
<button data-md="extract" data-help="Extract\nMoves selected text into a new card\nand inserts a [[link]] in its place." class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<button id="md-upload-pdf" data-help="Upload PDF\nSelect a PDF file to upload.\nInserts a download link into the card." class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
<button id="md-asset-picker" data-help="Insert from assets\nBrowse uploaded images, audio, and PDFs.\nClick a tile to insert into the editor." class="toolbar-btn" style="position:relative;"><i class="ph ph-images text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
<button id="md-find-replace" data-help="Find & replace\nSearch and replace text within this card.\nCtrl+H to open, Ctrl+F for find-only." class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
<?= PluginManager::instance()->slot('md-toolbar') ?>
<div class="flex-1"></div>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
<button id="toggle-scratchpad" class="toolbar-btn flex items-center gap-1.5 px-2" data-help="Scratchpad\nPrivate notes about this card.\nSaved separately, not part of the content."><i class="ph ph-notepad text-sm"></i><span class="font-pixel text-[9px]">Scratch</span></button>
</div>
<div class="flex flex-1 min-h-0 overflow-hidden">
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
@@ -2219,9 +2324,9 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button>
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button>
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button>
<button id="find-prev" class="toolbar-btn" data-tip="previous"><i class="ph ph-caret-up text-xs"></i></button>
<button id="find-next" class="toolbar-btn" data-tip="next"><i class="ph ph-caret-down text-xs"></i></button>
<button id="find-close" class="toolbar-btn" data-tip="close"><i class="ph ph-x text-xs"></i></button>
</div>
<div id="replace-row" class="flex items-center gap-2">
<i class="ph ph-swap text-xs text-t-dim"></i>
@@ -2232,11 +2337,19 @@ if (str_starts_with($route, '/api/')) {
</div>
<div class="relative flex-1 min-h-0">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
<textarea id="editor-body" placeholder="> write markdown..." spellcheck="false" autocorrect="off" autocapitalize="off" class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div>
</div>
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
<div id="editor-scratchpad" class="bg-t-bg flex flex-col hidden" style="flex:1 1 0%;min-width:0;">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-t-border shrink-0">
<i class="ph ph-notepad text-xs text-t-dim"></i>
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
</div>
<textarea id="scratchpad-body" placeholder="private notes..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
</div>
</div>
</div>
</div>
+33 -9
View File
@@ -13,11 +13,13 @@ import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './module
import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.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 { refreshTasksView, openTasks, closeTasks } from './modules/tasks.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) ──
@@ -40,7 +42,16 @@ window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window.openFindBar = openFindBar;
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
window._reloadAll = async () => {
await loadBuckets();
state.cards = await api('api/cards') || [];
renderBoard();
refreshCalendar();
// Let plugins / modules react to a full card reload (detail view checks
// whether the currently viewed card is now stale; collab-buckets uses it
// to refresh its decorators).
window.hyprcrd?.hooks?.emit?.('cards.reloaded');
};
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
window.state = state;
@@ -143,10 +154,14 @@ importFile?.addEventListener('change', async e => {
try {
const url = mode ? 'api/import?merge=1' : 'api/import';
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);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
} catch {}
} catch (err) {
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
}
e.target.value = '';
});
@@ -209,7 +224,8 @@ const { board: gBoard } = guards;
// — Escape chain (highest priority first) —
registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
registerShortcut({ key: 'Escape', priority: 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() });
@@ -266,6 +282,8 @@ registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
registerShortcut({ key: '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]')];
@@ -310,17 +328,23 @@ registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelect
const focused = document.querySelector('#board .card-focused');
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
}});
// Delete selected cards
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
const ids = getSelectedIds();
// 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 } = await import('./modules/board.js');
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();
}});
+6 -2
View File
@@ -584,8 +584,10 @@ export function renderBoard() {
renderGroupBar(cards);
if (!cards.length) {
board.style.display = '';
board.innerHTML = `<div class="col-span-full text-center py-20">
board.style.display = 'flex';
board.style.alignItems = 'center';
board.style.justifyContent = 'center';
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
const fc = $('footer-count');
@@ -707,6 +709,8 @@ export function renderBoard() {
}
board.style.display = '';
board.style.alignItems = '';
board.style.justifyContent = '';
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
bindCardEvents();
+28 -13
View File
@@ -294,11 +294,11 @@ function renderBar() {
// render (e.g. bucket reorder, plain renderBar() call).
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
<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 === '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" data-tip="current"><i class="ph ph-lightning 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" data-tip="journal"><i class="ph ph-notebook text-sm"></i></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" data-tip="unsorted"><i class="ph ph-tray text-sm"></i></button>
${topLevel.map(b => {
const kids = childrenOf(b.id);
const isSuper = kids.length > 0;
@@ -309,7 +309,7 @@ function renderBar() {
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><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`;
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><span class="super-caret" data-super-caret="${b.id}"><i class="ph ph-caret-down text-[10px]"></i></span></button>`;
}
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
@@ -400,7 +400,7 @@ function renderBar() {
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" title="parent"></i>' : ''}</button>`;
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" data-tip="parent"></i>' : ''}</button>`;
};
pop.innerHTML = members.map(renderEntry).join('');
// Append inside #bucket-bar (not document.body) so the collab
@@ -462,11 +462,21 @@ function renderBar() {
const isUser = isSuperTile || !SPECIAL.includes(bid);
if (isSuperTile) {
// Click opens the dropdown popover instead of activating the
// parent directly. The dropdown lists parent + every child.
t.addEventListener('click', (e) => {
e.stopPropagation();
openSuperDropdown(t, parseInt(bid, 10));
// 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(); });
@@ -598,8 +608,13 @@ function renderBar() {
}
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
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();
if (created?.id) {
setActiveBucket(created.id);
state.activeGroup = null;
}
renderBar();
window._renderBoard();
});
+9 -171
View File
@@ -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 { state } from './state.js';
const CAL_ICONS = [
{ 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('')}
</div>
<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>`;
}).join('');
@@ -119,7 +125,6 @@ export async function loadSettings() {
}
}
function setCaldavTestStatus(text, kind = 'info') {
if (!testStatus) return;
if (!text) {
@@ -170,52 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
testStatus.style.color = 'var(--t-accent)';
});
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
export async function triggerSync() {
const btn = $('cal-sync');
if (btn) btn.style.opacity = '0.5';
const headerIcon = $('header-sync-icon');
if (headerIcon) headerIcon.classList.add('card-spinner');
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/caldav/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
if (btn) btn.style.opacity = '';
// Also poll relay
// (header spinner stays on until the relay poll completes too — see end)
const relayRes = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (res.ok) {
const s = res.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
const allLog = res.log || [];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(allLog);
showSyncNotification(total);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
if (headerIcon) headerIcon.classList.remove('card-spinner');
}
function showLog(log) {
if (!logEl || !log?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = log.map(l =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).join('');
}
function showConnectionLog(lines) {
if (!logEl || !lines?.length) return;
logEl.classList.remove('hidden');
@@ -224,125 +183,4 @@ function showConnectionLog(lines) {
).join('');
}
// ── Relay settings ──
function setRelayStatus(text, kind = 'info') {
const el = $('relay-status');
if (!el) return;
if (!text) {
el.classList.add('hidden');
el.textContent = '';
el.style.borderColor = '';
el.style.color = '';
return;
}
el.classList.remove('hidden');
el.textContent = text;
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
}
$('relay-save')?.addEventListener('click', async () => {
const body = {
url: $('relay-url')?.value || '',
username: $('relay-username')?.value || '',
key: $('relay-key')?.value || '',
};
setRelayStatus('saving...');
await api('api/relay/settings', { method: 'POST', body });
setRelayStatus('saved', 'success');
setTimeout(() => setRelayStatus(''), 1500);
});
$('relay-test')?.addEventListener('click', async () => {
setRelayStatus('testing...');
const res = await fetch('api/relay/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ strict: true }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
if (res.pulled) await window._reloadAll?.();
} else {
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
setRelayStatus((res?.error || 'failed') + detail, 'error');
}
});
export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const userInput = $('relay-username');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
$('header-sync')?.addEventListener('click', () => triggerSync());
// Auto-sync throttle
let lastAutoSync = 0;
export function autoSync() {
if (window._testRunning) return;
const now = Date.now();
if (now - lastAutoSync < 60000) return;
lastAutoSync = now;
triggerSync();
}
// Background sync every 2 minutes
function showSyncNotification(stats) {
const s = stats || {};
const parts = [];
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.deleted) parts.push(`${s.deleted} deleted`);
if (!parts.length) return;
const note = document.createElement('div');
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
document.body.appendChild(note);
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
async function backgroundSync() {
if (state.bgSyncRunning) return;
state.bgSyncRunning = true;
try {
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, relayRes] = await Promise.all([
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const s = res?.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(total);
}
} catch {} finally {
state.bgSyncRunning = false;
}
}
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
backgroundSync();
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
}
export function stopBackgroundSync() {
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
}
+53 -12
View File
@@ -3,7 +3,8 @@
import { $, esc } from './util.js';
import { state } from './state.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';
const overlay = $('calendar');
@@ -88,20 +89,35 @@ function matchesRRule(card, targetDateStr) {
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) {
const results = [];
const targetYear = parseInt(ds.substring(0, 4));
for (const c of state.cards) {
if (cardBaseDate(c) === ds) {
const base = cardBaseDate(c);
if (base === ds) {
results.push({ ...c, _recurring: false });
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
} else if (base && base < ds) {
// 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 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')) {
// Local birthday cards repeat yearly without RRULE
const base = cardBaseDate(c);
if (base.substring(5) === ds.substring(5) && ds !== base) {
const age = targetYear - parseInt(base.substring(0, 4));
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
@@ -146,7 +162,25 @@ function clearCalHover() {
}
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 startOffset = (firstDay + 6) % 7; // Mon=0
@@ -210,9 +244,11 @@ function render() {
const calCfg = getCardCalConfig(c);
const itemColor = calCfg?.color || c.color;
const itemIcon = calCfg?.icon || null;
const md = c._multiDay;
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 (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>`;
html += `<span class="truncate">${esc(name)}</span>`;
html += `</div>`;
@@ -494,9 +530,14 @@ export function refreshCalendar() {
}
export function openCalendar() {
const now = new Date();
state.calYear = now.getFullYear();
state.calMonth = now.getMonth();
// Only jump to today on first open — preserve the user's navigation
// when the calendar is re-opened (e.g. after saving a card in a
// different month).
if (state.calYear == null || state.calMonth == null) {
const now = new Date();
state.calYear = now.getFullYear();
state.calMonth = now.getMonth();
}
render();
overlay.classList.add('open');
overlay.style.visibility = '';
+26 -18
View File
@@ -195,36 +195,44 @@ export function showContextMenu(x, y, card) {
</button>` : '');
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 has = cardTags.includes(tag);
let newBody = card.body || '';
// Re-read the current card from state to avoid stale 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) {
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
cardTags.splice(cardTags.indexOf(tag), 1);
} else {
newBody = newBody.trimEnd() + ' #' + tag;
cardTags.push(tag);
}
card.body = newBody;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == card.id);
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
// Trust the server's response for the new tags string
const c = state.cards.find(x => x.id == targetId);
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);
window._renderBoard();
}));
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 newBody = (card.body || '').trimEnd() + ' #' + tag;
card.body = newBody;
cardTags.push(tag);
allTags.push(tag);
allTags.sort();
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == card.id);
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
const fresh = state.cards.find(x => x.id == targetId) || card;
const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == targetId);
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
card.body = c?.body ?? newBody;
cardTags.length = 0;
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
tagInput.value = '';
renderCtxTags('');
window._renderBoard();
+100 -4
View File
@@ -72,9 +72,104 @@ export function openDetail(id, options = {}) {
renderSidebar(card);
showContent(card);
dismissStaleBanner();
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() {
if (morphTimer) {
clearTimeout(morphTimer);
@@ -370,6 +465,7 @@ export function closeDetail(options = {}) {
closeImageFullscreen();
detail.classList.remove('open', 'zen');
dismissStaleBanner();
removeZenHandles();
document.documentElement.classList.remove('detail-zen-open');
document.body.classList.remove('detail-zen-open');
@@ -433,7 +529,7 @@ function renderSidebar(card) {
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
${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>
<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>
</button>
</div>`;
@@ -554,7 +650,7 @@ function applyReadingResizeHandles(root) {
const line = document.createElement('button');
line.type = 'button';
line.className = 'detail-font-dragline';
line.title = 'drag to resize text';
line.dataset.tip = 'drag to resize text';
line.setAttribute('aria-label', 'resize text');
el.appendChild(line);
@@ -615,7 +711,7 @@ export function bindDetailImageFullscreen(root) {
// Regular images: double-click, no gallery nav
root.querySelectorAll('img').forEach(img => {
if (img.closest('.md-gallery')) return;
img.title = 'double-click to open fullscreen';
img.dataset.tip = 'double-click to fullscreen';
img.addEventListener('dblclick', e => {
e.preventDefault();
e.stopPropagation();
@@ -898,7 +994,7 @@ function loadSplitCard(id) {
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
</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>
</button>
</div>`;
+224 -16
View File
@@ -4,6 +4,7 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
import { state, getChildren, CARD_COLORS } from './state.js';
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
import { pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
const modal = $('modal');
const body = $('editor-body');
@@ -13,6 +14,8 @@ const inpTitle = $('inp-title');
const inpColor = $('inp-color');
const inpBucket = $('inp-bucket');
const presets = $('color-presets');
const scratchpadPanel = $('editor-scratchpad');
const scratchpadBody = $('scratchpad-body');
const linkAc = $('link-ac');
const tagAc = $('tag-ac');
@@ -102,7 +105,7 @@ function startCloseMorph() {
CARD_COLORS.forEach(c => {
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.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
btn.style.background = c.hex;
@@ -176,6 +179,45 @@ function selectColor(hex) {
// ── 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) {
state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card';
@@ -189,25 +231,59 @@ export function openModal(card = null) {
selectColor(card ? card.color : '#38bdf8');
setDueDate(card?.due_date || '');
updateDoneToggle();
state.previewVisible = true;
preview.classList.remove('hidden');
editorDivider?.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
applyEditorSplit();
updatePreview();
if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
showSidePanel('preview');
modal.classList.add('open');
if (linesVisible) updateGutter();
currentLine = -1;
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();
// 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 => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
});
body.addEventListener('input', saveDraftDebounced);
scratchpadBody?.addEventListener('input', saveDraftDebounced);
// ── Line numbers ──
@@ -363,6 +439,7 @@ export function closeModal({ animate = true } = {}) {
hideTagAc();
hideSnippetAc();
closeFindBar();
closeAssetPicker();
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
@@ -395,13 +472,16 @@ function updatePreview() {
}
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;
const returnToGallery = state.returnToGalleryAfterEdit;
const wasEditingId = state.editingId;
if (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 });
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
clearDraft(state.editingId);
} else {
if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup);
@@ -418,6 +498,7 @@ async function doSave(close, { animate = true } = {}) {
state._lastSavedId = created.id;
state.recentlyCreatedIds?.add(String(created.id));
}
clearDraft(null); // 'new' draft
}
await window._reloadAll();
// Auto-sync if card has #termin or #geburtstag
@@ -535,18 +616,40 @@ function applyEditorSplit() {
preview.style.flex = `${100 - editorSplit} 1 0`;
}
$('toggle-preview')?.addEventListener('click', () => {
state.previewVisible = !state.previewVisible;
preview.classList.toggle('hidden', !state.previewVisible);
editorDivider?.classList.toggle('hidden', !state.previewVisible);
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
if (state.previewVisible) {
function showSidePanel(which) {
const showPreview = which === 'preview';
const showScratch = which === 'scratchpad';
state.previewVisible = showPreview;
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();
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
} else {
if (editorPane) editorPane.style.flex = '1 1 0%';
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) {
@@ -1184,9 +1287,114 @@ $('md-upload-pdf-file')?.addEventListener('change', e => {
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 ──
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const img = e.target.closest('.prose img');
if (!img || img.closest('.md-gallery-item')) return;
e.preventDefault();
+3 -3
View File
@@ -170,9 +170,9 @@ export async function openGallery() {
<div class="relative">
${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button>
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button>
<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-orphan="${f.name}" data-tip="new card"><i class="ph ph-plus text-sm"></i></button>
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" 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 class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
+1 -1
View File
@@ -146,7 +146,7 @@ export function renderMd(src) {
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const 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>`;
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);
+1 -1
View File
@@ -73,7 +73,7 @@ export function render() {
<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>` : ''}
</div>`).join('')}
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" title="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" data-tip="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
</div>`;
banner.querySelectorAll('[data-notif-id]').forEach(el => {
+60 -43
View File
@@ -1,13 +1,11 @@
// ── Plugin Settings UI ──
// Renders the list of installed plugins with on/off toggles in the
// settings overlay. Disabled plugins are skipped at PHP render time;
// toggling requires a reload to take effect (page must re-fetch the
// new script/css/html tag set).
// 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 dirtyAfterToggle = false;
let pending = new Map(); // name -> desired enabled state
async function fetchPlugins() {
const res = await fetch('api/plugins');
@@ -23,63 +21,86 @@ function render(list) {
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
return;
}
container.innerHTML = list.map(p => `
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
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" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}">
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${effectiveEnabled ? 'checked' : ''} data-name="${esc(p.name)}" data-original="${p.enabled ? '1' : '0'}">
</label>
</div>
`).join('');
</div>`;
}).join('');
container.querySelectorAll('.plugin-toggle').forEach(input => {
input.addEventListener('change', onToggle);
});
updateApplyBar();
}
let reloadTimer = null;
async function onToggle(e) {
function onToggle(e) {
const input = e.currentTarget;
const name = input.dataset.name;
const enabled = input.checked;
input.disabled = true;
try {
const res = await api('api/plugins/toggle', {
method: 'POST',
body: { name, enabled },
});
if (res.error) throw new Error(res.error);
pluginsCache = res.plugins;
dirtyAfterToggle = true;
$('plugins-reload-notice')?.classList.remove('hidden');
// Auto-reload so the effect of the toggle is visible immediately.
// Debounced so rapid successive toggles only trigger one reload.
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => location.reload(), 400);
} catch (err) {
// Revert UI on failure
input.checked = !enabled;
console.error('[plugins] toggle failed:', err);
} finally {
input.disabled = false;
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>';
if (!dirtyAfterToggle) {
$('plugins-reload-notice')?.classList.add('hidden');
}
try {
const list = await fetchPlugins();
render(list);
@@ -88,9 +109,5 @@ export async function openPluginSettings() {
}
}
// Wire reload button once at module load
document.addEventListener('DOMContentLoaded', () => {
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
}, { once: true });
// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
$('plugins-apply-btn')?.addEventListener('click', applyChanges);
$('plugins-discard-btn')?.addEventListener('click', discardChanges);
+362
View File
@@ -0,0 +1,362 @@
// ── Sync Orchestration ─────────────────────────────────────────────
//
// Owns the single source of truth for "time to sync". Every scheduled
// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
// the chat plugin — hangs off the same interval via the `sync.tick` hook
// instead of running its own setInterval. That way:
//
// - The header Sync button triggers ALL syncs (emits `sync.tick`).
// - The Autosync toggle pauses ALL syncs (stops the single timer).
// - The configurable interval changes ALL cadences at once.
//
// This module also owns the autosync button UI, the sync-event toast
// feed, and the relay settings panel (which hosts the interval input).
//
// Hooks emitted:
// sync.tick — fire all pollers now
// sync.pulse — pulse the header dot (subset of tick)
// sync.interval.changed — restart timers with new cadence
// autosync.changed — user paused/resumed scheduled polling
//
// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
// in caldav.js. This file has no CalDAV icons/colors concerns.
import { $, api, getCsrf } from './util.js';
import { state } from './state.js';
const syncStatus = $('caldav-sync-status');
// ── Manual "Sync now" trigger ───────────────────────────────────
// Fired by the header Sync button, the CalDAV panel "sync now" button,
// and the `autoSync()` throttled helper. Always runs regardless of the
// autosync-paused setting — the user pressed a button, we honor that.
// Fans out to every plugin poller via `sync.tick`.
export async function triggerSync() {
pulseAutosyncDot();
const btn = $('cal-sync');
if (btn) btn.style.opacity = '0.5';
const headerIcon = $('header-sync-icon');
if (headerIcon) headerIcon.classList.add('card-spinner');
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/caldav/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
if (btn) btn.style.opacity = '';
// Also poll relay for mail-style messages. Header spinner stays on
// until this completes too.
const relayRes = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.ok ? r.json() : null).catch(() => null);
drainSyncEvents(relayRes);
// Fan out to plugin pollers (collab-buckets, chat, …). They run
// concurrently with us — no await needed here.
window.hyprcrd?.hooks?.emit?.('sync.tick');
if (res.ok) {
const s = res.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
const allLog = res.log || [];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(allLog);
showSyncNotification(total);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
if (headerIcon) headerIcon.classList.remove('card-spinner');
}
// CalDAV log display inside the Settings → CalDAV panel. Lives here
// (not in caldav.js) because triggerSync writes to it.
function showLog(log) {
const logEl = $('caldav-log');
if (!logEl || !log?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = log.map(l =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).join('');
}
// ── Auto-sync throttle ──────────────────────────────────────────
// Used by the CalDAV module when the user saves a card with #termin or
// #geburtstag — we want to fire a sync immediately but not storm the
// server if they paste a big batch. 60s cooldown between auto-fires.
let lastAutoSync = 0;
export function autoSync() {
if (window._testRunning) return;
const now = Date.now();
if (now - lastAutoSync < 60000) return;
lastAutoSync = now;
triggerSync();
}
// ── Sync notifications ──────────────────────────────────────────
function showSyncNotification(stats) {
const s = stats || {};
const parts = [];
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.deleted) parts.push(`${s.deleted} deleted`);
if (!parts.length) return;
const note = document.createElement('div');
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
document.body.appendChild(note);
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
// One toast per incoming sync event: "alice synced: Card Title".
// Stacks downward so a burst of multiple doesn't overlay each other.
let syncEventToastOffset = 0;
function showSyncEventToast(ev) {
const body = String(ev?.body || '').trim();
if (!body) return;
const bottom = 110 + syncEventToastOffset * 48;
syncEventToastOffset++;
const note = document.createElement('div');
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}</div>`;
document.body.appendChild(note);
setTimeout(() => {
note.style.transition = 'opacity 0.3s';
note.style.opacity = '0';
setTimeout(() => {
note.remove();
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
}, 350);
}, 5000);
}
function drainSyncEvents(relayRes) {
if (relayRes?.sync_debug) {
console.debug('[sync-events]', relayRes.sync_debug);
}
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
for (const ev of events) showSyncEventToast(ev);
}
// ── Scheduled background pull ───────────────────────────────────
// Internal helper — the CalDAV + relay mail side of a tick. Plugin
// pollers run concurrently via the `sync.tick` hook.
async function backgroundPull() {
if (state.bgSyncRunning) return;
state.bgSyncRunning = true;
try {
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, relayRes] = await Promise.all([
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const s = res?.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(total);
}
drainSyncEvents(relayRes);
} catch {} finally {
state.bgSyncRunning = false;
}
}
// Central tick: the one and only scheduled sync heartbeat. Fires the
// CalDAV+relay pull and emits `sync.tick` for every other poller.
function tick() {
pulseAutosyncDot();
backgroundPull();
window.hyprcrd?.hooks?.emit?.('sync.tick');
}
// ── Configurable sync interval ────────────────────────────────
// One setting drives every scheduled poller. Changed via the Relay
// settings panel; emits `sync.interval.changed` so the central timer
// restarts on the new cadence without a page reload.
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
const SYNC_INTERVAL_DEFAULT = 15;
const SYNC_INTERVAL_MIN = 5;
const SYNC_INTERVAL_MAX = 3600;
export function getSyncIntervalSec() {
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
}
export function setSyncIntervalSec(sec) {
const n = parseInt(sec);
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
}
// ── Autosync on/off toggle ────────────────────────────────────
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
export function isAutoSyncEnabled() {
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
}
export function setAutoSyncEnabled(on) {
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
if (on) startBackgroundSync();
else stopBackgroundSync();
updateAutosyncButton();
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
}
// ── Central tick timer ────────────────────────────────────────
let tickInterval = null;
export function startBackgroundSync() {
if (tickInterval) return;
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
tick();
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
updateAutosyncButton();
}
export function stopBackgroundSync() {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
updateAutosyncButton();
}
// Restart the central timer when the user changes the interval.
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
}
});
// ── Header autosync button ────────────────────────────────────
function updateAutosyncButton() {
const btn = $('header-autosync');
if (!btn) return;
const on = isAutoSyncEnabled();
btn.dataset.autosync = on ? 'on' : 'off';
btn.setAttribute('data-tip', on
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
: 'auto-sync: paused — click to resume');
}
// Pulse the dot once. Rip and re-add the class after a reflow so
// re-triggering mid-animation restarts cleanly.
export function pulseAutosyncDot() {
const dot = $('header-autosync-dot');
if (!dot) return;
dot.classList.remove('pulsing');
void dot.offsetWidth;
dot.classList.add('pulsing');
}
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
updateAutosyncButton();
// ── Sync-adjacent button wiring ───────────────────────────────
$('cal-sync')?.addEventListener('click', () => triggerSync());
$('header-sync')?.addEventListener('click', () => triggerSync());
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
// ── Relay settings panel ──────────────────────────────────────
// The relay panel lives in Settings → Relay. It holds the URL /
// username / shared-key inputs plus the shared sync-interval number
// input that drives every scheduled poller.
function setRelayStatus(text, kind = 'info') {
const el = $('relay-status');
if (!el) return;
if (!text) {
el.classList.add('hidden');
el.textContent = '';
el.style.borderColor = '';
el.style.color = '';
return;
}
el.classList.remove('hidden');
el.textContent = text;
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
}
$('relay-save')?.addEventListener('click', async () => {
const body = {
url: $('relay-url')?.value || '',
username: $('relay-username')?.value || '',
key: $('relay-key')?.value || '',
};
setRelayStatus('saving...');
await api('api/relay/settings', { method: 'POST', body });
setRelayStatus('saved', 'success');
setTimeout(() => setRelayStatus(''), 1500);
});
$('relay-test')?.addEventListener('click', async () => {
setRelayStatus('testing...');
const res = await fetch('api/relay/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ strict: true }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
if (res.pulled) await window._reloadAll?.();
drainSyncEvents(res);
} else {
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
setRelayStatus((res?.error || 'failed') + detail, 'error');
}
});
export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const userInput = $('relay-username');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
// Sync-interval slider lives in the Relay panel but drives every
// poller. Snap points span 5s..1h; live label updates on drag,
// persisted value commits on release.
const intervalInp = $('setting-sync-interval');
const intervalVal = $('setting-sync-interval-val');
if (intervalInp) {
const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
const fmt = (s) => {
if (s < 60) return `${s}s`;
if (s === 3600) return '1h';
const m = Math.floor(s / 60), r = s % 60;
return r ? `${m}m ${r}s` : `${m}m`;
};
const idxFor = (sec) => {
let best = 0, bestDiff = Infinity;
for (let i = 0; i < STEPS.length; i++) {
const d = Math.abs(STEPS[i] - sec);
if (d < bestDiff) { bestDiff = d; best = i; }
}
return best;
};
intervalInp.max = String(STEPS.length - 1);
const sync = (idx) => {
const sec = STEPS[idx];
if (intervalVal) intervalVal.textContent = fmt(sec);
return sec;
};
intervalInp.value = String(idxFor(getSyncIntervalSec()));
sync(parseInt(intervalInp.value));
intervalInp.oninput = () => sync(parseInt(intervalInp.value));
intervalInp.onchange = () => {
const sec = sync(parseInt(intervalInp.value));
setSyncIntervalSec(sec);
};
}
}
-14
View File
@@ -96,8 +96,6 @@ function updatePreview() {
set('--tp-bar-height', d.barHeight + 'px');
set('--tp-pad-card', d.padCard + 'px');
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
const scan = $('theme-preview-scan');
if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : 'none';
}
function fillFontSelect(id, value) {
@@ -129,8 +127,6 @@ function syncControlsFromPending() {
const valEl = $(valId);
if (valEl) valEl.textContent = v;
}
const scanEl = $('setting-scanlines');
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
}
function commitDesign() {
@@ -167,16 +163,6 @@ function initDesignSettings() {
if (designWired) return;
designWired = true;
// Scanlines toggle
const scanEl = $('setting-scanlines');
const scanRow = scanEl?.closest('.toggle-row');
if (scanRow) scanRow.onclick = () => {
const nowOn = !scanEl.classList.contains('on');
scanEl.classList.toggle('on', nowOn);
designPending.scanlines = nowOn ? '1' : '0';
commitDesign();
};
// Selects
for (const [id, key] of SELECT_FIELDS) {
const el = $(id);
+93
View File
@@ -0,0 +1,93 @@
// ── Global tooltip system ──
// Replaces native title="" with styled tooltips. Supports:
// data-tip="short text" — single-line tooltip (replaces title)
// data-help="line1\nline2" — multi-line inline help (shown on hover)
const SHOW_DELAY = 400;
const HIDE_DELAY = 100;
const OFFSET = 8;
let tipEl = null;
let showTimer = null;
let hideTimer = null;
let currentTarget = null;
function create() {
if (tipEl) return;
tipEl = document.createElement('div');
tipEl.id = 'tooltip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
}
function show(target) {
clearTimeout(hideTimer);
if (currentTarget === target) return;
currentTarget = target;
create();
const tip = target.dataset.tip || '';
const help = target.dataset.help || '';
if (!tip && !help) { hide(); return; }
if (help) {
tipEl.classList.add('tooltip-help');
tipEl.innerHTML = help.replace(/\\n/g, '<br>');
} else {
tipEl.classList.remove('tooltip-help');
tipEl.textContent = tip;
}
tipEl.style.visibility = 'hidden';
tipEl.style.display = 'block';
const rect = target.getBoundingClientRect();
const tipRect = tipEl.getBoundingClientRect();
let top, left;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow >= tipRect.height + OFFSET || spaceBelow >= spaceAbove) {
top = rect.bottom + OFFSET;
tipEl.classList.remove('tooltip-above');
} else {
top = rect.top - tipRect.height - OFFSET;
tipEl.classList.add('tooltip-above');
}
left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
top = Math.max(4, top);
tipEl.style.top = top + 'px';
tipEl.style.left = left + 'px';
tipEl.style.visibility = '';
}
function hide() {
currentTarget = null;
if (tipEl) tipEl.style.display = 'none';
}
function onEnter(e) {
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
const target = el?.closest?.('[data-tip],[data-help]');
if (!target) return;
clearTimeout(hideTimer);
showTimer = setTimeout(() => show(target), SHOW_DELAY);
}
function onLeave(e) {
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
const target = el?.closest?.('[data-tip],[data-help]');
if (!target) return;
clearTimeout(showTimer);
hideTimer = setTimeout(hide, HIDE_DELAY);
}
document.addEventListener('pointerenter', onEnter, true);
document.addEventListener('pointerleave', onLeave, true);
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true);
+9 -9
View File
@@ -3,13 +3,13 @@
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
<!-- Toolbar -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
<button data-tool="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>
@@ -24,9 +24,9 @@
<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" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" data-tip="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div>
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button>
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" data-tip="Clear"><i class="ph ph-trash text-sm"></i></button>
<div class="flex-1"></div>
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
</div>
+47 -4
View File
@@ -142,15 +142,58 @@
/* ── Thread view ── */
#chat-thread-header {
/* Horizontal peer strip above the thread header. Lets the user hop
between conversations without bouncing back to the list. Scrollbar
hidden — wheel-over scrolls horizontally (see chat.js). */
.chat-thread-peers {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
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;
+7 -9
View File
@@ -3,9 +3,11 @@
<div id="chat-flyout" class="hidden">
<div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" title="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" title="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
<span class="flex-1"></span>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div>
@@ -21,15 +23,11 @@
<!-- Conversation thread view -->
<div id="chat-thread-view" class="chat-view hidden">
<div id="chat-thread-header">
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<i class="ph ph-user-circle text-t-accent text-sm"></i>
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
</div>
<div id="chat-thread-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" title="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" data-tip="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
</form>
</div>
+64 -2
View File
@@ -142,6 +142,7 @@ function showListView() {
$('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden');
currentPeer = null;
}
@@ -150,14 +151,17 @@ function showThreadView(peer) {
$('chat-thread-view').classList.remove('hidden');
$('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat';
$('chat-thread-peer').textContent = peer;
$('chat-header-back')?.classList.remove('hidden');
currentPeer = peer;
// Re-render tabs so the active highlight follows the thread switch.
renderPeerTabs();
}
function showPickView() {
$('chat-list-view').classList.add('hidden');
$('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
}
// ── Conversation list ─────────────────────────────────────────────
@@ -174,6 +178,7 @@ function renderConversations() {
const list = $('chat-list');
const empty = $('chat-empty');
if (!list || !empty) return;
renderPeerTabs();
if (!conversations.length) {
list.innerHTML = '';
empty.classList.remove('hidden');
@@ -205,6 +210,56 @@ function renderConversations() {
});
}
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
// Horizontal row above the thread so the user can hop between
// conversations without going back to the list. Hidden scrollbar;
// vertical wheel delta is redirected to horizontal scroll.
function renderPeerTabs() {
const strip = $('chat-thread-peers');
if (!strip) return;
// Fold the current peer in even if no messages exist yet (fresh
// pick-view selection), so switching back to it via tab is always
// possible.
const peers = conversations.slice();
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
peers.unshift({ peer: currentPeer, unread: 0 });
}
if (!peers.length) { strip.innerHTML = ''; return; }
strip.innerHTML = peers.map(c => {
const active = c.peer === currentPeer ? ' active' : '';
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
? '<span class="chat-peer-dot"></span>' : '';
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
}).join('');
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const peer = tab.dataset.peer;
if (peer && peer !== currentPeer) openConversation(peer);
});
});
// Keep the active tab in view after a switch so the user can see
// what's selected when the list overflows.
const active = strip.querySelector('.chat-peer-tab.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// Redirect vertical wheel scrolls to horizontal scroll on the peer
// strip. Attached once on init — preventDefault only when we actually
// consume the delta, so a trackpad user's native horizontal gesture
// still works.
function wirePeerStripWheel() {
const strip = $('chat-thread-peers');
if (!strip || strip.dataset.wheelWired) return;
strip.dataset.wheelWired = '1';
strip.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (strip.scrollWidth <= strip.clientWidth) return;
e.preventDefault();
strip.scrollLeft += e.deltaY;
}, { passive: false });
}
// ── Thread ────────────────────────────────────────────────────────
async function openConversation(peer) {
@@ -380,9 +435,10 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView);
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); });
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
$('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage);
wirePeerStripWheel();
// Esc closes flyout when focus is inside it
$('chat-flyout').addEventListener('keydown', e => {
@@ -410,6 +466,12 @@ function init() {
restartPoll();
}
});
// Also refresh on the global sync heartbeat. Our own timer keeps
// the faster flyout-open cadence (UX-specific); tick guarantees
// that the header Sync button refreshes chat too, even when the
// flyout is closed and our own timer sits at 20s.
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
}
if (document.readyState === 'loading') {
+162 -9
View File
@@ -49,10 +49,20 @@
bucket_uid TEXT NOT NULL,
last_push_at TEXT,
last_remote_at TEXT,
last_seen_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
// Per-card unread tracking (added 2026-04-14). Existing deployments need
// the column added explicitly.
$ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name');
if (!in_array('last_seen_at', $ccsCols)) {
$db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT");
// Backfill: everything existing gets seen=now so we don't flood the
// user with a board full of red dots after they deploy this.
$db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')");
}
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
// table. The poll's push phase drains it and sends each entry as a
@@ -411,9 +421,16 @@ function collabSyncCards(PDO $db): array {
$stats['moved']++;
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
// Card is gone or moved out of collab entirely — forget about it
// Card is gone or moved out of collab entirely — forget about it.
// Critical: also clear the card's relay_uid so the incoming
// tombstone (echoed back from the relay on next pull) doesn't
// find this card and delete it locally too.
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
->execute(['id' => $mv['card_id']]);
if (!$mv['card_missing']) {
$db->prepare('UPDATE cards SET relay_uid = NULL WHERE id = :id')
->execute(['id' => $mv['card_id']]);
}
} else {
// Card moved to a different collab bucket. Rewrite state so the
// push phase below picks it up under the new bucket_uid and also
@@ -436,17 +453,19 @@ function collabSyncCards(PDO $db): array {
FROM cards c
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
LEFT JOIN collab_card_state cs ON cs.card_id = c.id
WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at
WHERE (cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at)
AND c.updated_at < datetime('now', '-2 seconds')
")->fetchAll();
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
$stateUpsert = $db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2)
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at
last_push_at = excluded.last_push_at,
last_seen_at = excluded.last_seen_at
");
foreach ($dirty as $card) {
@@ -459,6 +478,31 @@ function collabSyncCards(PDO $db): array {
$payload = collabEncryptPayload(collabBuildCardPayload($card));
if ($payload === null) continue; // no relay_key or openssl failure
// Attachments FIRST, card second. If a peer polls between the two
// HTTP calls, they'd see the new card body with an `uploads/xyz.png`
// reference but the attachment wouldn't be on the relay yet — their
// fetch returns empty and the image renders broken permanently
// (the pull short-circuits on the card's next poll because
// updated_at doesn't change). Pushing attachments first guarantees
// the relay's state is consistent: if the card is visible, its
// images are fetchable.
$body = $card['body'] ?? '';
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
foreach (array_unique($fm[1]) as $fn) {
$localPath = UPLOAD_DIR . '/' . $fn;
if (!is_file($localPath)) continue;
$bytes = file_get_contents($localPath);
if ($bytes === false) continue;
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
if ($encoded === null) continue;
collabRelayCall('/api/collab-attachments', 'POST', [
'bucket_uid' => $card['bucket_uid'],
'filename' => $fn,
'data' => $encoded,
]);
}
}
$res = collabRelayCall('/api/collab-cards', 'POST', [
'bucket_uid' => $card['bucket_uid'],
'card_uid' => $relayUid,
@@ -483,6 +527,7 @@ function collabSyncCards(PDO $db): array {
'ru' => $relayUid,
'bu' => $card['bucket_uid'],
'push' => $res['updated_at'] ?? $card['updated_at'],
'push2'=> $res['updated_at'] ?? $card['updated_at'],
]);
$stats['pushed']++;
}
@@ -543,6 +588,33 @@ function collabSyncCards(PDO $db): array {
$fields = collabDecryptPayload($c['payload'] ?? '');
if ($fields === null) continue;
// Fetch referenced attachments BEFORE applying the body so that
// the moment state.cards reflects the new body (via _reloadAll),
// every `uploads/xxx.png` reference is already on disk. Without
// this, a user who opens the card preview between apply and
// fetch would get a broken-image render — and the browser's
// 404 response can stick around in the page's image cache even
// after the file finally lands.
$pulledBody = $fields['body'] ?? '';
if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) {
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
foreach (array_unique($fm[1]) as $fn) {
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn));
if (!($attRes['ok'] ?? false)) continue;
foreach (($attRes['attachments'] ?? []) as $a) {
if (($a['filename'] ?? '') !== $fn) continue;
$decoded = collabDecryptPayload($a['data'] ?? '');
if ($decoded && isset($decoded['raw'])) {
$bytes = base64_decode($decoded['raw']);
if ($bytes !== false) file_put_contents($dest, $bytes);
}
}
}
}
if ($local) {
// LWW: only accept remote if it's strictly newer than local
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
@@ -602,24 +674,49 @@ function collabSyncCards(PDO $db): array {
$localId = (int)$db->lastInsertId();
}
// Record the remote state so we don't push it back on the next poll
// Record the remote state so we don't push it back on the next
// poll. last_seen_at semantics: self-authored updates (our own
// echo) mark as seen; foreign-authored updates leave last_seen_at
// untouched so the red dot fires on the board.
$seenAt = $foreignAuthor ? null : $c['updated_at'];
$db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
VALUES (:cid, :ru, :bu, :p, :r)
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
VALUES (:cid, :ru, :bu, :p, :r, :s)
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at,
last_remote_at = excluded.last_remote_at
last_remote_at = excluded.last_remote_at,
last_seen_at = CASE
WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at
ELSE collab_card_state.last_seen_at
END
")->execute([
'cid' => $localId,
'ru' => $c['card_uid'],
'bu' => $c['bucket_uid'],
'p' => $c['updated_at'],
'r' => $c['updated_at'],
's' => $seenAt,
]);
$stats['pulled']++;
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
// Notify the card's author that we synced it (best-effort sync
// event → toast on the author's side). Only fires for cards
// authored by someone else.
if ($foreignAuthor && !empty($c['author_username'])) {
$cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)';
$evRes = collabRelayCall('/api/sync-events', 'POST', [
'recipient' => $c['author_username'],
'body' => $selfUser . ' synced: ' . $cardTitle,
]);
@error_log('[hyprcrd] sync-event POST → ' . $c['author_username']
. ' ok=' . ($evRes['ok'] ?? 'n/a')
. ' status=' . ($evRes['status'] ?? '-')
. ' err=' . substr((string)($evRes['error'] ?? ''), 0, 120));
}
}
} finally {
// Bump `collab_buckets.updated_at` for every bucket that received
@@ -641,6 +738,40 @@ function collabSyncCards(PDO $db): array {
}
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
// Self-heal phase: walk every card in a collab bucket and try to fetch
// any `uploads/xxx.png` reference whose file isn't on disk. Covers the
// historical race where a card body arrived before its attachment, as
// well as any one-off fetch failure (network blip, timeout) that left
// a card with a broken image reference. Normal-case cost is trivial:
// one SQL, one `file_exists()` per reference, no HTTP when everything
// is already in place.
$missing = $db->query("
SELECT c.id, c.body, b.collab_uid AS bucket_uid
FROM cards c
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
WHERE c.body LIKE '%uploads/%'
")->fetchAll();
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
foreach ($missing as $mc) {
if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue;
foreach (array_unique($fm[1]) as $fn) {
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn));
if (!($attRes['ok'] ?? false)) continue;
foreach (($attRes['attachments'] ?? []) as $a) {
if (($a['filename'] ?? '') !== $fn) continue;
$decoded = collabDecryptPayload($a['data'] ?? '');
if ($decoded && isset($decoded['raw'])) {
$bytes = base64_decode($decoded['raw']);
if ($bytes !== false) file_put_contents($dest, $bytes);
}
}
}
}
return $stats;
}
@@ -837,6 +968,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// Any pending outbox entries still waiting for a reachable relay
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
// Per-card unread set: cards whose local updated_at is newer than
// their last_seen_at (or never seen). Frontend uses this to draw
// the little red dot on the board card.
$unreadCards = array_map('intval', $db->query("
SELECT cs.card_id
FROM collab_card_state cs
JOIN cards c ON c.id = cs.card_id
WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at
")->fetchAll(PDO::FETCH_COLUMN));
collabJson([
'ok' => true,
'self' => collabRelaySettings()['user'],
@@ -847,6 +988,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
'locks' => $locks,
'outbox_processed' => $outboxProcessed,
'outbox_pending' => $outboxPending,
'unread_cards' => $unreadCards,
]);
}
@@ -861,6 +1003,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
collabJson(['ok' => true]);
}
// ── Mark a single collab card as seen (clears its red dot) ────
if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$cardId = (int)($input['card_id'] ?? 0);
if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400);
Database::connect()
->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c")
->execute(['c' => $cardId]);
collabJson(['ok' => true]);
}
// ── Proxy: relay tenants (for the create-bucket member picker) ──
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
// so we wrap it here to match the shape the frontend expects.
@@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
.collab-conflict-toast.collab-lock-toast b {
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;
}
+102 -17
View File
@@ -4,9 +4,9 @@
// rows in the standard bucket bar. The plugin just owns the sync + the
// visual unread-state decoration:
//
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
// mirrors each remote bucket into the main `buckets` table so the core
// bucket bar renders it automatically.
// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
// in sync.js). The backend mirrors each remote bucket into the main
// `buckets` table so the core bucket bar renders it automatically.
// - Exposes `window._collabBucketsCreateDialog` — an async function that
// the core + Bucket button calls so we can pop a richer "new bucket"
// modal with an optional "collaborative" checkbox + member picker.
@@ -24,8 +24,11 @@ const $ = (id) => document.getElementById(id);
// uid -> collab bucket meta (from /api/collab-buckets)
let collabByUid = new Map();
let pollHandle = null;
const POLL_INTERVAL_MS = 30000;
// card_id (number) -> true for cards with foreign edits we haven't opened yet
let unreadCards = new Set();
// Collab card sync is driven by the shared `sync.tick` hook from
// sync.js — one timer fans out to every plugin poller, the central
// timer honors the Autosync pause for us.
// Phase 4: edit locks (now exclusive — server-enforced)
// card_uid -> { bucket_uid, holder_username, expires_at }
@@ -58,8 +61,16 @@ async function loadCollabMeta() {
} catch (err) { /* silent; relay may be offline */ }
}
let pollLocked = false;
async function pollOnce() {
if (pollLocked) return;
// Don't poll while user is actively editing — the push would race
// with the in-progress save and corrupt card state.
if (document.getElementById('modal')?.classList.contains('open')) return;
try {
pollLocked = true;
hyprcrd.hooks?.emit?.('sync.pulse');
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return;
if (res.self) window._collabSelf = res.self;
@@ -85,6 +96,12 @@ async function pollOnce() {
const n = res.outbox_processed;
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
}
// Per-card unread list: cards with foreign edits we haven't opened
// yet. Decorated on the board via decorateBoardUnread().
if (Array.isArray(res.unread_cards)) {
unreadCards = new Set(res.unread_cards.map(Number));
decorateBoardUnread();
}
// Phase 4: edit-lock list comes back with the poll
if (Array.isArray(res.locks)) {
const next = new Map();
@@ -112,7 +129,7 @@ async function pollOnce() {
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
showBlockedToast(by);
}
} catch { /* silent */ }
} catch { /* silent */ } finally { pollLocked = false; }
}
function showConflictToast(n) {
@@ -149,13 +166,11 @@ function showQueuedToast(message) {
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
function startPolling() {
stopPolling();
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
// Subscribe to the global sync heartbeat. The central timer in sync.js
// honors the Autosync pause + the configurable interval for us, and
// the header Sync button emits the same tick so manual sync pushes
// collab cards too.
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
// ── Bucket bar decoration ─────────────────────────────────────────
@@ -631,12 +646,71 @@ function decorateBoardLocks() {
}
}
/**
* Walk every rendered board card and add / remove the "red dot" unread
* indicator for cards with foreign edits we haven't opened yet.
* Idempotent — safe to re-run on every board re-render.
*/
function decorateBoardUnread() {
const board = document.getElementById('board');
if (!board) return;
for (const el of board.querySelectorAll('.board-card[data-id]')) {
const cid = parseInt(el.dataset.id);
const isUnread = unreadCards.has(cid);
el.classList.toggle('collab-card-unread', isUnread);
let dot = el.querySelector('.collab-card-dot');
if (isUnread && !dot) {
dot = document.createElement('span');
dot.className = 'collab-card-dot';
el.appendChild(dot);
} else if (!isUnread && dot) {
dot.remove();
}
}
}
// Clear a card's unread state locally + on the backend. Called from
// - board click (piggy-backing on the natural open-card interaction)
// - detail.opened hook (when the detail view opens via any path:
// keyboard shortcut, related-card link, backlink, search result, etc.)
function clearCardUnread(cid) {
cid = parseInt(cid);
if (!cid || !unreadCards.has(cid)) return;
unreadCards.delete(cid);
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
if (el) {
el.classList.remove('collab-card-unread');
el.querySelector('.collab-card-dot')?.remove();
}
hyprcrd.api('api/collab-buckets/mark-card-seen', {
method: 'POST', body: { card_id: cid },
}).catch(() => {});
}
function setupCardClickInterceptor() {
const board = document.getElementById('board');
if (board) {
board.addEventListener('click', (e) => {
const el = e.target.closest('.board-card[data-id]');
if (el) clearCardUnread(el.dataset.id);
});
}
// Detail view has its own open path (keyboard shortcuts, backlinks,
// related-card navigation) that doesn't route through a board-card
// click. Listen for the explicit detail.opened hook so every open
// method clears the dot.
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
}
// Re-decorate whenever the board re-renders (reuses the existing #board
// MutationObserver pattern).
function setupBoardObserver() {
const board = document.getElementById('board');
if (!board) return;
const obs = new MutationObserver(() => decorateBoardLocks());
const obs = new MutationObserver(() => {
decorateBoardLocks();
decorateBoardUnread();
});
obs.observe(board, { childList: true, subtree: false });
}
@@ -892,19 +966,30 @@ async function init() {
}
setupBarObserver();
setupClickInterceptor();
setupCardClickInterceptor();
setupBoardObserver();
await loadCollabMeta();
await pollOnce();
await loadCollabMeta();
decorateBucketBar();
decorateBoardLocks();
startPolling();
decorateBoardUnread();
// No local timer: subsequent polls come from the global `sync.tick`
// hook (central timer in sync.js honors Autosync pause + interval).
}
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
// boot, and firing POST /api/collab-buckets/poll before that racing lookup
// resolves returns 403. If the token is already in the util state when we
// load (e.g. fast reload, cached session), kick off init straight away.
function bootWhenCsrfReady() {
if (hyprcrd?.util?.csrf?.()) { init(); return; }
hyprcrd?.hooks?.on?.('csrf.ready', () => init());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
document.addEventListener('DOMContentLoaded', bootWhenCsrfReady, { once: true });
} else {
init();
bootWhenCsrfReady();
}
hyprcrd.registerPlugin?.({
+12 -12
View File
@@ -7,10 +7,10 @@
<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" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
<button id="email-refresh" class="toolbar-btn" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
<div class="flex-1"></div>
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="manage accounts"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
</div>
@@ -41,11 +41,11 @@
<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" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
<button id="email-page-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" title="Select all messages flagged as spam">
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="select spam">
<i class="ph ph-warning-octagon text-sm"></i>
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
</button>
@@ -54,16 +54,16 @@
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
<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" title="Mark sender as spam (future mail from same sender will be flagged)">
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="mark as spam">
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
</button>
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)">
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" data-tip="not spam">
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
</button>
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server">
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="Delete selected on server">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
</button>
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection">
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Clear selection">
<i class="ph ph-x text-sm"></i>
</button>
</div>
@@ -91,9 +91,9 @@
</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" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
<button id="email-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>
+6 -6
View File
@@ -218,9 +218,9 @@ function renderAccountsList() {
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
</div>
<div class="email-account-actions">
<button class="toolbar-btn email-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn email-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
<button class="toolbar-btn email-edit-btn" data-tip="edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn email-test-btn" data-tip="test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn email-del-btn" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
</div>
</div>
`).join('');
@@ -452,13 +452,13 @@ function renderMessageList() {
const spamScore = m.spam?.score ?? 0;
const spamReasons = (m.spam?.reasons || []).join(' · ');
const spamBadge = spamLevel === 'spam'
? `<span class="email-spam-badge spam" title="${escapeHtml(spamReasons)}">⚠ spam</span>`
? `<span class="email-spam-badge spam" data-tip="${escapeHtml(spamReasons)}">⚠ spam</span>`
: spamLevel === 'suspicious'
? `<span class="email-spam-badge suspicious" title="${escapeHtml(spamReasons)}">? suspicious</span>`
? `<span class="email-spam-badge suspicious" data-tip="${escapeHtml(spamReasons)}">? suspicious</span>`
: '';
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
return `
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" title="${escapeHtml(rowTitle)}">
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" data-tip="${escapeHtml(rowTitle)}">
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
<div class="email-msg-content">
<div class="flex items-baseline justify-between gap-2">
@@ -0,0 +1,487 @@
// ── Example content plugin — one-click demo data ──
const h = window.hyprcrd;
const api = h?.api || window._exampleApi;
const COLORS = {
blue: '#38bdf8', purple: '#a78bfa', pink: '#f472b6', red: '#fb7185',
green: '#34d399', yellow: '#fbbf24', orange: '#fb923c', slate: '#64748b',
};
const CARDS = [
{
title: 'Architecture RFC',
color: COLORS.blue,
body: `## System Architecture
The new microservice layout separates auth, data, and notification layers.
### Key decisions
- PostgreSQL for persistent state
- Redis for session cache + pub/sub
- gRPC between internal services
- REST + WebSocket for client API
### Open questions
- [ ] Rate limiting strategy
- [ ] Multi-region failover
- [x] Auth token rotation scheme
- [x] Database migration tooling
See also: [[Deploy checklist]]
#project/api #architecture`,
},
{
title: 'Sprint Planning Q3',
color: COLORS.purple,
body: `## Goals
1. Finish API v3 migration
2. Launch mobile auth flow
3. Load testing at 10k concurrent
4. Cut staging→prod deploy time to <5min
**Deadline:** 2026-07-01
**Owner:** @alice
### Capacity
| Team | Points | Allocated |
|----------|--------|-----------|
| Backend | 34 | 30 |
| Frontend | 21 | 18 |
| Infra | 13 | 13 |
#meeting #project`,
},
{
title: 'Auth timeout on mobile',
color: COLORS.red,
body: `Users on iOS Safari report being logged out after ~3 minutes of inactivity.
### Repro
1. Login on iPhone 15 (Safari 18)
2. Switch to another app for 3 min
3. Return → "session expired"
### Investigation
- Cookie \`SameSite=Strict\` might be too aggressive
- Safari's ITP may be purging the session cookie
- Backend session TTL is 24h — not the issue
### Fix
Try \`SameSite=Lax\` + short-lived refresh token.
#bug #urgent #project/frontend/auth`,
},
{
title: 'Deploy checklist',
color: COLORS.green,
body: `- [x] Run full test suite
- [x] Update CHANGELOG.md
- [x] Bump version in config
- [ ] Tag release in git
- [ ] Build Docker image
- [ ] Push to registry
- [ ] Roll out to staging
- [ ] Smoke test staging
- [ ] Promote to production
- [ ] Monitor Grafana for 30min
> Last deploy: 2026-04-08 by @bob
> Rollback: \`git revert HEAD && ./deploy.sh\`
#ops #deploy`,
},
{
title: 'Meeting 2026-04-15 — Retro',
color: COLORS.yellow,
due_date: '2026-04-15',
body: `**Zeit:** 14:00-15:00
**Ort:** Room 42
**Teilnehmer:** Alice, Bob, Carol
---
### What went well
- Deploy frequency improved (3x/week → daily)
- Zero P1 incidents this sprint
- New search feature got good feedback
### What to improve
- PR review turnaround still >24h
- Flaky integration tests causing false alarms
- Documentation gaps in relay module
### Action items
- [ ] @alice: set up PR review SLA
- [ ] @bob: fix top 5 flaky tests
- [ ] @carol: write relay onboarding doc
#meeting #termin #retro`,
},
{
title: 'Reading list',
color: COLORS.slate,
body: `Papers and articles to read:
- [Designing Data-Intensive Applications](https://dataintensive.net/) — Ch. 7-9
- [The Twelve-Factor App](https://12factor.net/)
- \`CAP theorem revisited\` — Brewer 2012
- Local-first software — Kleppmann et al.
### Notes
> "The truth is the log. The database is a cache."
> — Pat Helland
#personal #research`,
},
{
title: 'API v3 migration',
color: COLORS.blue,
body: `### Endpoints to migrate
| v2 | v3 | Status |
|----|-----|--------|
| GET /users | GET /api/v3/users | done |
| POST /auth | POST /api/v3/auth/token | done |
| GET /cards | GET /api/v3/cards | in progress |
| WS /stream | WS /api/v3/events | planned |
### Breaking changes
- Auth header: \`Bearer\` instead of \`X-Token\`
- Pagination: cursor-based instead of offset
- Error format: \`{"error": {"code": ..., "message": ...}}\`
[[Architecture RFC]]
#project/api #migration`,
},
{
title: 'Infra cost review',
color: COLORS.orange,
body: `### Monthly breakdown (April)
\`\`\`
Compute (k8s) €340 ████████░░ 68%
Storage (S3) €85 ██░░░░░░░░ 17%
Database (RDS) €52 █░░░░░░░░░ 10%
CDN + DNS €18 ░░░░░░░░░░ 4%
Monitoring €8 ░░░░░░░░░░ 2%
─────────────────────────────────────
Total €503
\`\`\`
Budget: €600 → **€97 headroom**
Next action: evaluate spot instances for batch workers.
#ops #infra #finance`,
},
{
title: 'Birthday Alice',
color: COLORS.pink,
due_date: '2026-06-12',
body: `#geburtstag`,
},
{
title: 'Onboarding: new developer',
color: COLORS.green,
body: `## First week checklist
- [ ] Laptop + accounts provisioned
- [ ] SSH key added to Gitea
- [ ] Access to staging cluster
- [x] Slack channels joined
- [x] 1:1 with team lead
- [ ] Read [[Architecture RFC]]
- [ ] First PR: fix a good-first-issue
### Contacts
| Role | Person |
|------|--------|
| Buddy | @carol |
| Team lead | @alice |
| Infra | @dave |
#onboarding #ops`,
},
{
title: 'Competitive analysis',
color: COLORS.purple,
body: `### Landscape
| Product | Self-hosted | Offline | E2E encryption |
|---------|:-----------:|:-------:|:--------------:|
| Notion | no | no | no |
| Obsidian| yes | yes | paid |
| Logseq | yes | yes | no |
| **hyprcrd** | **yes** | **yes** | **yes** |
### Differentiators
- Tag extraction from body (zero-friction categorization)
- Relay model: multi-tenant sync without cloud dependency
- Plugin system: extensible without forking
- CalDAV native (not just ical export)
#research #strategy`,
},
{
title: 'Security audit notes',
color: COLORS.red,
body: `## Audit 2026-Q1 findings
### Critical
~~1. Session token in URL parameter~~ → fixed in v1.2.1
### High
2. CSRF token not rotated on privilege change → [[Auth timeout on mobile]]
### Medium
3. Upload directory allows .svg (XSS vector)
4. Rate limiter not applied to /api/export
### Low
5. Server version exposed in HTTP headers
6. No CSP header on main app
> Next audit scheduled: 2026-07-15
#security #audit #urgent`,
},
{
title: 'Design system tokens',
color: COLORS.purple,
body: `### Color palette
\`\`\`
--t-bg: #0f172a base background
--t-surface: #1e293b card/panel surface
--t-border: #334155 borders
--t-text: #94a3b8 body text
--t-bright: #e2e8f0 headlines
--t-accent: #38bdf8 primary accent
--t-accent2: #a78bfa secondary accent
--t-danger: #fb7185 errors/destructive
\`\`\`
### Typography
- **UI:** Silkscreen (pixel font)
- **Content:** JetBrains Mono
- **Sizes:** 9px labels, 11px body, 13px headings
### Radius scale
\`--ui-radius-card\`, \`--ui-radius-modal\`, \`--ui-radius-input\`
All overridable per theme preset.
#design #frontend`,
},
{
title: 'Customer feedback digest',
color: COLORS.yellow,
body: `### Week of 2026-04-07
**Positive:**
- "Search is blazing fast" — 3 mentions
- "CalDAV sync just works" — 2 mentions
- "Love the keyboard shortcuts" — @user42
**Requests:**
- Mobile app (→ Electron PWA?)
- Shared boards without relay (→ collab-buckets?)
- Dark mode for relay UI (→ already shipped!)
**Issues:**
- Image upload fails >5MB on shared hosting
- Calendar popup overlaps on 13" screens
#feedback #product`,
},
{
title: 'Database schema notes',
color: COLORS.slate,
body: `### Core tables
\`\`\`sql
cards (id, title, body, color, tags,
bucket_id, parent_id, position,
due_date, relay_uid,
created_at, updated_at)
buckets (id, name, color, parent_id,
collab_uid, position)
settings (key, value)
\`\`\`
### Indexes
- \`cards.bucket_id\` — bucket filter
- \`cards.parent_id\` — group lookup
- \`buckets.collab_uid UNIQUE\` — relay sync
SQLite WAL mode for concurrent reads.
#architecture #database`,
},
{
title: 'Release notes v1.3',
color: COLORS.green,
body: `## What's new
### Features
- **Tooltip system** — custom styled tooltips replace all native titles
- **Asset picker** — thumbnail grid in the editor toolbar
- **Super-bucket split** — click activates, caret opens dropdown
- **Watch plugin** — analog clock overlay with SVG watchface
- **Card peek** — hover preview with rendered markdown
### Fixes
- Shortcuts no longer fire when typing in inputs
- Gallery opens even if API fetch fails
- Relay poll suppressed during user actions
- Find/replace integrated into search overlay
### Breaking
- All \`title=""\` attributes migrated to \`data-tip=""\`
#release #changelog`,
},
{
title: '2026-04-12',
color: COLORS.slate,
body: `## Journal
Started the day reviewing PRs. Merged the tooltip system and the asset picker.
Spent the afternoon on the Electron build — static PHP binary works but the WAL/SHM files caused issues. Fixed by deleting stale lock files.
Had a good session on the watch plugin. The wobble animation on the second hand is a nice touch.
**Tomorrow:**
- [ ] Test relay collab-buckets with two instances
- [ ] Write release notes
- [ ] Screenshot for website
#journal`,
},
{
title: 'Backup strategy',
color: COLORS.orange,
body: `### Current
Daily cron:
\`\`\`bash
#!/bin/bash
DATE=$(date +%Y-%m-%d)
cp data/hyprcrd.db "/backup/hyprcrd-$DATE.db"
tar czf "/backup/uploads-$DATE.tar.gz" public/uploads/
find /backup -mtime +30 -delete
\`\`\`
### Planned
- [ ] Automated export ZIP via API
- [ ] Offsite copy to S3-compatible storage
- [ ] Test restore monthly
- [ ] Document in runbook
> WAL checkpoint before copy:
> \`sqlite3 data/hyprcrd.db "PRAGMA wal_checkpoint(TRUNCATE);"\`
#ops #backup #infra`,
},
{
title: 'Plugin ideas',
color: COLORS.pink,
body: `### Brainstorm
- **Kanban columns** — cards as swimlanes per bucket
- **Time tracking** — start/stop timer per card, weekly report
- **AI summary** — auto-generate card summaries with local LLM
- **RSS reader** — fetch feeds into cards
- **Webhook triggers** — POST on card create/update/delete
- **PDF export** — render card as styled PDF
- **Voice memo** — record audio, attach to card, transcribe
### Criteria for inclusion
1. Does it help *find* information?
2. Can it be a drop-in plugin (no core changes)?
3. Is it useful without an internet connection?
#idea #plugins #brainstorm`,
},
{
title: 'Monitoring setup',
color: COLORS.green,
body: `### Stack
- **Uptime:** UptimeRobot (free tier, 5min interval)
- **Errors:** Sentry self-hosted → \`sentry.internal\`
- **Metrics:** Grafana + Prometheus
- **Logs:** Loki + promtail on each node
### Alerts
| Metric | Threshold | Channel |
|--------|-----------|---------|
| HTTP 5xx rate | >1% for 5min | Slack #ops-alerts |
| Response time p95 | >2s for 10min | Slack #ops-alerts |
| Disk usage | >85% | Email + Slack |
| SSL expiry | <14 days | Email |
Dashboard: \`grafana.internal/d/hyprcrd\`
#ops #monitoring #infra`,
},
];
async function generate() {
const bucket = await api('api/buckets', { method: 'POST', body: { name: 'Project Alpha' } });
const bid = bucket?.id;
if (!bid) return false;
await api(`api/buckets/${bid}`, { method: 'PATCH', body: { color: COLORS.blue } });
for (const c of CARDS) {
await api('api/cards', {
method: 'POST',
body: { title: c.title, body: c.body, color: c.color, bucket_id: bid, due_date: c.due_date || null },
});
}
return true;
}
function init() {
const footer = document.getElementById('main-footer');
if (!footer) return;
const btn = document.createElement('button');
btn.className = 'flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
btn.dataset.tip = 'Create example bucket with demo cards';
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.innerHTML = '<i class="ph ph-spinner text-sm"></i><span class="font-pixel text-[10px]">creating...</span>';
const ok = await generate();
if (ok) {
btn.innerHTML = '<i class="ph ph-check text-sm"></i><span class="font-pixel text-[10px]">Done</span>';
await window._reloadAll?.();
} else {
btn.innerHTML = '<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Failed</span>';
}
setTimeout(() => {
btn.innerHTML = '<i class="ph ph-flask text-sm"></i><span class="font-pixel text-[10px]">Demo</span>';
btn.disabled = false;
}, 2000);
});
const firstChild = footer.querySelector('div');
if (firstChild) firstChild.appendChild(btn);
h?.registerPlugin({ name: 'example-content', version: '1.0.0' });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
@@ -0,0 +1,8 @@
{
"name": "example-content",
"version": "1.0.0",
"description": "One-click demo content for screenshots and first-time exploration.",
"frontend": {
"js": ["example-content.js"]
}
}
+11 -1
View File
@@ -27,10 +27,16 @@ require_once __DIR__ . '/GiteaClient.php';
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) ────────
@@ -85,7 +91,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route === '/api/gitea/accounts' && $method === 'GET') {
$db = Database::connect();
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
$rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
giteaJson($rows);
}
@@ -132,6 +138,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
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);
+15 -15
View File
@@ -9,7 +9,7 @@
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
<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" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
</div>
@@ -40,25 +40,25 @@
<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" title="Issue state">
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Issue state">
<option value="open">open</option>
<option value="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" title="Filter by your involvement">
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Filter by your involvement">
<option value="all">everyone</option>
<option value="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" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
<button id="gitea-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" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
<button id="gitea-page-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>
@@ -83,7 +83,7 @@
<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" title="Cancel"><i class="ph ph-x text-sm"></i></button>
<button id="gitea-create-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="space-y-2">
<label class="block relative">
@@ -140,12 +140,12 @@
<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" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
</div>
<div 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" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
<button id="gitea-title-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>
@@ -158,7 +158,7 @@
</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" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit labels"><i class="ph ph-tag text-sm"></i></button>
</div>
<div 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>
@@ -171,7 +171,7 @@
<!-- 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" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
</div>
<div 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">
@@ -192,7 +192,7 @@
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
<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" title="Clear due date"><i class="ph ph-x text-sm"></i></button>
<button id="gitea-meta-due-clear" class="toolbar-btn" data-tip="Clear due date"><i class="ph ph-x text-sm"></i></button>
</div>
</div>
</div>
@@ -213,7 +213,7 @@
<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" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
</div>
<div 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>
+16 -6
View File
@@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) {
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<div class="flex items-center gap-2 pt-1">
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
@@ -150,12 +154,12 @@ function renderAccountsList() {
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
<div class="gitea-account-info">
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div>
<div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
</div>
<div class="gitea-account-actions">
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
<button class="toolbar-btn gitea-edit-btn" data-tip="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn gitea-test-btn" data-tip="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn gitea-del-btn" data-tip="Delete"><i class="ph ph-trash text-sm"></i></button>
</div>
</div>
`).join('');
@@ -201,6 +205,7 @@ function resetAccountForm() {
$('gitea-acc-name').value = '';
$('gitea-acc-baseurl').value = '';
$('gitea-acc-token').value = '';
$('gitea-acc-default-repo').value = '';
setAccountStatus('');
$('gitea-tok-hint').textContent = '';
}
@@ -214,6 +219,7 @@ function editAccount(id) {
$('gitea-acc-name').value = acc.name;
$('gitea-acc-baseurl').value = acc.base_url;
$('gitea-acc-token').value = '';
$('gitea-acc-default-repo').value = acc.default_repo || '';
$('gitea-tok-hint').textContent = '(leave blank to keep existing)';
setAccountStatus('');
$('gitea-acc-name').focus();
@@ -240,6 +246,7 @@ async function saveAccount(e) {
name: $('gitea-acc-name').value.trim(),
base_url: $('gitea-acc-baseurl').value.trim(),
token: $('gitea-acc-token').value,
default_repo: $('gitea-acc-default-repo').value.trim(),
};
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
setAccountStatus('name, base url, and token are required', 'error');
@@ -713,8 +720,9 @@ async function openCreateForm() {
$('gitea-create-form').classList.remove('hidden');
$('gitea-create-form').classList.add('flex');
// Reset form
$('gitea-create-repo').value = '';
// Reset form — pre-fill default repo if configured
const currentAccount = accounts.find(a => a.id === currentAccountId);
$('gitea-create-repo').value = currentAccount?.default_repo || '';
$('gitea-create-title').value = '';
$('gitea-create-body').value = '';
setCreateStatus('');
@@ -766,6 +774,8 @@ async function openCreateForm() {
errBox.classList.remove('hidden');
}
}
if (currentAccount?.default_repo) loadCreateRepoLabels();
}
function updateRepoHint() {
+36
View File
@@ -0,0 +1,36 @@
# oumta-dashboard plugin
Fullscreen view for the Oumta cluster dashboard (Go backend in `oumta-dashboard/`).
## Open
- **Oumta** button in the hyprcrd top bar (next to *Tasks*)
- `window._oumtaToggle()` from the console / other plugins
Close with **ESC** or the × button.
## Views
- **Overview** — cluster state, workload counts, attention list
- **Workloads** — filterable table (name, state, replicas, cpu/mem, image, reason)
- **Timeline** — event stream; clicking a workload filters down to its own events
## Namespaces
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
## Settings
Open the hyprcrd settings overlay and choose the **oumta** tab:
- **API Base URL** — e.g. `http://localhost:8080` for local dev, or the cluster-internal URL
- **Auth token** — optional, sent as `Authorization: Bearer …`. Will eventually be filled automatically once Keycloak is wired up.
- **Poll interval** — polling fallback cadence when SSE is unavailable. Default 15 s.
## State today
Everything is mocked. The live indicator in the top right shows `polling` once the overlay is open, but no real HTTP call fires yet. See [`TODO.md`](./TODO.md) for the integration plan.
## Disabling
Add `"oumta-dashboard"` to the `disabled` array in `data/plugins.json`.
+172
View File
@@ -0,0 +1,172 @@
# oumta-dashboard — TODO
Everything in this file is work the plugin still needs to do before it's more than a mockup. Organised by phase.
## Status
- **Phase 0 (current)**: UI shell + mock data. Everything renders but no backend call is real.
- **Phase 1**: connect to real Go backend (`oumta-dashboard` repo) as endpoints become available.
- **Phase 2**: Keycloak bearer auth, PHP proxy decision, persisted settings.
- **Phase 3**: nice-to-haves (card auto-creation on incident, etc.).
---
## Phase 1 — wire up the real API
All API calls live in `oumta-dashboard.js` → `api()`. Every case currently returns a `mock…` helper; swap them for `fetch` against the backend. The URL is constructed from `state.settings.baseUrl`.
### Endpoints to wire (listed in the order the UI consumes them)
| UI call | Backend endpoint (planned) | Status in backend | Notes |
|---|---|---|---|
| `api('/api/namespaces')` | `GET /api/namespaces` | **not yet implemented** | returns the namespace tabs shown at the top. See "Open backend questions" below. |
| `api('/api/cluster/overview')` | `GET /api/cluster/overview?namespace=…` | **exists (stub)** | current stub returns zeros. Also need `namespace` query param. |
| `api('/api/workloads')` | `GET /api/workloads?namespace=…&state=…&since=…` | not yet | filters: `state`, `since` (ISO), also maybe `search`. |
| `api('/api/workloads/{ns}/{name}/timeline')` | `GET /api/workloads/{ns}/{name}/timeline` | not yet | workload-detail timeline |
| `api('/api/timeline')` | `GET /api/timeline?namespace=…` | not yet | global timeline |
### Real `fetch` implementation sketch
In `api()` replace the mock dispatch with:
```js
const url = new URL(path, state.settings.baseUrl);
if (!url.searchParams.has('namespace') && state.activeNamespace)
url.searchParams.set('namespace', state.activeNamespace.namespace);
const headers = state.settings.authToken
? { Authorization: 'Bearer ' + state.settings.authToken }
: {};
const res = await fetch(url, { ...opts, headers });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
```
There's also a commented-out sketch inside `api()` itself — delete the mock branches once real endpoints work.
### Contract alignment — [REAL] vs [PROPOSED] fields
Mocks in `oumta-dashboard.js` are labelled inline:
- **[REAL]** — field matches the Go struct exactly (from `internal/httpapi/router.go`). Must stay byte-for-byte aligned when flipping to real.
- **[PROPOSED]** — our guess based on prose in backend `docs/README.md`. Backend author may pick different names; UI has one place per field to update, and there's a commented-out adapter slot in `api()` if several diverge.
**Overview** (`GET /api/cluster/overview`) — entirely [REAL]:
```
clusterState (string: healthy | degraded | down | unknown)
workloadsUp (int)
workloadsDown (int)
updatedAtUtc (string, RFC3339)
```
Extras shown on the Overview page (incidents count, attention list) are **derived client-side** from the workloads response — no contract addition needed from backend.
**Workloads** (`GET /api/workloads`) — [PROPOSED], derived from `docs/README.md §2`:
```
items: [{
workloadId // synthetic handle, format "deployment/{ns}/{name}"
name // ← from doc "Name"
namespace // ← "Namespace"
state // ← "Zustand" — vocab: healthy | degraded | unavailable
replicasReady // ← "Replikas (ready/…)"
replicasDesired // ← "Replikas (…/desired)"
cpu // ← "CPU-Auslastung"
memory // ← "Speicherverbrauch"
image // ← "Aktives Docker-Image"
lastReason // ← "Letzter bekannter Grund fuer Zustandswechsel"
lastTransitionAt // ← "inkl. letzter Statuswechsel-Zeit"
}]
```
**Timeline** (`GET /api/timeline`, `GET /api/workloads/{ns}/{name}/timeline`) — [PROPOSED], derived from `docs/README.md §3`:
```
items: [{
timestamp // RFC3339
severity // info | warning | error
workloadId // same handle as above
message // short human description
reason // cause code, e.g. CrashLoopBackOff, ImagePullBackOff
kind // deployment | incident | scale — optional UI hint, drop if absent
}]
```
**Namespaces** (`GET /api/namespaces`) — [PROPOSED], new endpoint:
```
items: [{ id, label, namespace }]
```
`id` could equal `namespace` if backend treats them as the same. `label` is optional display text.
If/when real shapes differ, the fix path in priority order:
1. Rename in `mockXxx()` + renderer (one place each).
2. If multiple fields diverge on one endpoint, add a mapping step inside `api()` before `return`.
3. If the shape differs fundamentally, split the renderer.
---
## Phase 2 — auth, persistence, proxy decision
### Keycloak bearer flow
- Decision pending (see user note): probably SSO-wrapped. The plugin already sends `Authorization: Bearer <state.settings.authToken>` if a token is configured; the open question is **how the token arrives**.
- Options:
- **A.** User pastes a token into the settings tab (good for POC, not for prod).
- **B.** hyprcrd itself becomes a Keycloak client, acquires a token, exposes it via `window.hyprcrd.authToken` — plugin reads that. Requires a hyprcrd-core change.
- **C.** PHP proxy (see below) attaches the token server-side; the browser never sees it.
- **Recommendation**: go with **C** once we decide on the proxy. Until then **A** is fine for dev.
### PHP proxy in hyprcrd
- Today the plugin calls the Go backend **directly from the browser**. That means:
- The Go backend must return permissive CORS (it does: `Access-Control-Allow-Origin` is wide-open in dev).
- The user's browser must reach the backend (issue if the backend lives inside a private network).
- If we add a PHP proxy (file: `public/plugins/oumta-dashboard/backend.php`), we'd:
- Route `/api/oumta/*` → forward to `baseUrl + *`.
- Store `baseUrl` + `authToken` in hyprcrd's `settings` table instead of `localStorage`.
- Get CORS for free because the request originates from hyprcrd's own origin.
- **Trigger for adding the proxy**: when we get the Keycloak spec, or when users report "dashboard only works when I'm VPN'd".
### Settings persistence
- Currently `localStorage['oumta-dashboard-settings']` — fine for a single user in one browser.
- If we want per-tenant server-side settings, add a hyprcrd `settings` key like `oumta.base_url` and expose via a small PHP endpoint.
---
## Phase 3 — realtime
- `startLive()` currently just starts polling (every `settings.pollIntervalSec`).
- SSE plan (see commented-out block in `startLive()`):
- Backend exposes `GET /api/events?namespace=…` as an `EventSource`-compatible stream.
- On `onopen` → `setLive('live')`, apply incoming events live (invalidate `state.data.overview` etc. or surgically patch).
- On `onerror` → fall back to polling, `setLive('polling')` with a hint.
- SSE event shape proposal (to negotiate with backend): `{ type: 'workload.state', workloadId, state, reason, timestamp }`, etc. Parking this until the backend is ready to talk.
---
## Phase 3+ — nice to haves (not in scope yet)
From the original questions catalog we parked:
- Timezone handling: UTC vs. local browser time (probably just use local + tooltip with UTC).
- Plugin settings in hyprcrd's settings-tab vs. per-user vs. shared across tenants.
- Auto-create a hyprcrd card when an incident is opened (tie the two systems together).
- Filter persistence in URL hash so links are shareable.
- Light/dark accent variants — currently inherits theme variables, should just work.
---
## Open backend questions (to sync with the Go team)
1. **Namespace list endpoint** — UI needs an endpoint to populate the namespace tabs. The doc says "Namespace-Auswahl per Menü" and mentions env-var config on the backend. What's the endpoint? `GET /api/namespaces`? If not, how does the UI discover them?
2. **Response envelope** — the existing `overview` endpoint returns a flat object. Lists should probably be `{ items: [...] }` (consistent with our renderers) or `[]` (then we'll adapt). Pick one and stick with it across all list endpoints.
3. **Cluster state values** — our Overview card colour-codes on `healthy | degraded | down | unknown`. Backend currently returns `"unknown"` as a literal. Confirm the vocabulary.
4. **Workload state values** — we colour-code on `healthy | degraded | unavailable`. The doc uses these but the old .NET API also had `withRestarts` / `withWarnings` as summary counters. Only the three we use? Or more?
5. **Workload ID shape** — we use `deployment/{namespace}/{name}` as a synthetic id (so workloads can be linked from the timeline). Does the backend emit that exact form, or just `{namespace}/{name}`? We can adapt either way.
6. **Timeline pagination** — limit? cursor? since? The .NET version had all three; the Go doc mentions only filters. Enough for MVP?
7. **Realtime channel** — path + event format. We've got a plan above; needs the backend team's blessing.
---
## Internal follow-ups
- `renderWorkloads` is called on every keystroke in the filter — fine up to ~1k rows, might need debouncing above that.
- `refresh()` tears down the scoped-workload timeline whenever it fires. For Phase 1 that's acceptable; once real data lands, consider NOT overwriting `state.data.timeline` when `state.data.scopeWorkload` is active.
- `bootstrap()` only runs on first show. If the user changes the API base URL in settings while the overlay is open, we re-start live mode but don't re-fetch namespaces. Add a "reset & reload" action if that matters.
- Namespace tab bar shows `namespace.label`; we currently fake it. Once real, consider showing the full k8s namespace if `label` and `namespace` differ.
@@ -0,0 +1,234 @@
/* ── Namespace tabs ── */
.oumta-ns-tab {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 10px;
padding: 8px 14px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--t-muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
margin-right: 2px;
}
.oumta-ns-tab:hover { color: var(--t-bright); }
.oumta-ns-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
/* ── View tabs ── */
.oumta-view-tab {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 10px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
color: var(--t-muted);
cursor: pointer;
border-radius: var(--ui-radius-button);
transition: all 0.15s;
}
.oumta-view-tab:hover { color: var(--t-bright); }
.oumta-view-tab.active { color: var(--t-accent); border-color: var(--t-accent); }
.oumta-view.hidden { display: none; }
.oumta-view { padding: 20px 24px; }
/* ── Overview cards ── */
.oumta-overview-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.oumta-card {
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-card);
padding: 14px 16px;
display: flex; flex-direction: column; gap: 4px;
}
.oumta-card-wide { grid-column: span 4; }
.oumta-card-label {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.oumta-card-value {
font-family: var(--font-content, monospace);
font-size: 28px;
color: var(--t-bright);
font-weight: bold;
}
.oumta-card-value-sm { font-size: 14px; font-weight: normal; }
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
.oumta-danger { color: var(--t-danger); }
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
.oumta-cluster-status[data-state="down"] .oumta-card-value { color: var(--t-danger); }
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
/* ── Attention list ── */
.oumta-section { margin-top: 8px; }
.oumta-section-title {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 10px;
color: var(--t-accent);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.oumta-attention-list { display: flex; flex-direction: column; gap: 6px; }
.oumta-attention-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px;
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-card);
cursor: pointer;
transition: border-color 0.15s;
}
.oumta-attention-row:hover { border-color: var(--t-accent); }
.oumta-attention-name { flex: 1; color: var(--t-bright); font-size: 13px; }
.oumta-attention-ns { color: var(--t-dim); font-size: 11px; font-family: var(--font-content, monospace); }
.oumta-attention-reason { color: var(--t-muted); font-size: 11px; font-style: italic; }
.oumta-attention-state {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
padding: 2px 6px;
border: 1px solid var(--t-border);
border-radius: 2px;
}
.oumta-attention-state[data-state="degraded"] { color: #fb923c; border-color: #fb923c; }
.oumta-attention-state[data-state="unavailable"] { color: var(--t-danger); border-color: var(--t-danger); }
.oumta-empty {
padding: 32px;
text-align: center;
color: var(--t-dim);
font-size: 12px;
font-style: italic;
}
/* ── Workload toolbar + table ── */
.oumta-toolbar {
display: flex; gap: 10px; margin-bottom: 14px;
align-items: center;
}
.oumta-input {
background: var(--t-bg);
border: 1px solid var(--t-border);
color: var(--t-bright);
padding: 6px 10px;
border-radius: var(--ui-radius-input);
font-size: 12px;
font-family: var(--font-content, monospace);
outline: none;
}
.oumta-input:focus { border-color: var(--t-accent); }
#oumta-wl-search { flex: 1; min-width: 200px; }
.oumta-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.oumta-table th {
text-align: left;
padding: 8px 10px;
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-dim);
text-transform: uppercase;
border-bottom: 1px solid var(--t-border);
letter-spacing: 0.5px;
}
.oumta-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--t-border);
color: var(--t-text);
font-family: var(--font-content, monospace);
vertical-align: middle;
}
.oumta-table tbody tr { cursor: pointer; transition: background 0.15s; }
.oumta-table tbody tr:hover { background: var(--t-bg); }
.oumta-table .wl-state-healthy { color: #4ade80; }
.oumta-table .wl-state-degraded { color: #fb923c; }
.oumta-table .wl-state-unavailable{ color: var(--t-danger); }
/* ── Timeline ── */
.oumta-timeline-header {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
}
.oumta-btn-ghost {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 10px;
padding: 4px 8px;
background: transparent;
border: 1px solid var(--t-border);
color: var(--t-muted);
cursor: pointer;
border-radius: var(--ui-radius-button);
transition: all 0.15s;
}
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
.oumta-timeline-body {
display: flex; flex-direction: column; gap: 2px;
border-left: 2px solid var(--t-border);
padding-left: 16px;
}
.oumta-timeline-entry {
display: grid;
grid-template-columns: 140px 80px 1fr;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--t-border);
align-items: baseline;
}
.oumta-timeline-time { font-family: var(--font-content, monospace); font-size: 11px; color: var(--t-dim); }
.oumta-timeline-severity {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
text-transform: uppercase;
}
.oumta-timeline-severity[data-sev="info"] { color: var(--t-accent); }
.oumta-timeline-severity[data-sev="warning"] { color: #fb923c; }
.oumta-timeline-severity[data-sev="error"] { color: var(--t-danger); }
.oumta-timeline-message { font-size: 12px; color: var(--t-bright); }
.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; }
/* ── Live indicator ── */
.oumta-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--t-dim);
display: inline-block;
transition: background 0.2s;
}
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #fb923c; }
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
/* ── Settings tab ── */
.oumta-settings-field {
display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px;
}
.oumta-settings-field label {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px; color: var(--t-dim); text-transform: uppercase;
}
.oumta-settings-field input {
background: var(--t-bg);
border: 1px solid var(--t-border);
color: var(--t-bright);
padding: 8px 10px;
border-radius: var(--ui-radius-input);
font-family: var(--font-content, monospace);
font-size: 12px;
outline: none;
}
.oumta-settings-field input:focus { border-color: var(--t-accent); }
.oumta-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
@@ -0,0 +1,117 @@
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<!-- Header -->
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
<i class="ph ph-gauge text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">oumta_dashboard</h2>
<span id="oumta-generated-at" class="font-pixel text-[9px] text-t-dim"></span>
<div class="flex-1"></div>
<button id="oumta-refresh" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Refresh now">
<i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">Refresh</span>
</button>
<button id="oumta-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
</button>
</div>
<!-- Namespace tabs -->
<div id="oumta-ns-tabs" class="flex items-center gap-0 border-b border-t-border shrink-0 px-5"></div>
<!-- View tabs (Overview / Workloads / Timeline) -->
<div class="flex items-center gap-4 border-b border-t-border shrink-0 px-5 py-2 bg-t-bg">
<button class="oumta-view-tab active" data-view="overview"><i class="ph ph-squares-four text-sm"></i> Overview</button>
<button class="oumta-view-tab" data-view="workloads"><i class="ph ph-list text-sm"></i> Workloads</button>
<button class="oumta-view-tab" data-view="timeline"><i class="ph ph-clock-counter-clockwise text-sm"></i> Timeline</button>
<div class="flex-1"></div>
<span id="oumta-live-indicator" class="flex items-center gap-1 font-pixel text-[9px] text-t-dim" data-tip="Realtime status">
<span class="oumta-dot"></span><span id="oumta-live-label">connecting…</span>
</span>
</div>
<!-- Body -->
<div class="flex-1 overflow-auto" id="oumta-body">
<!-- Overview -->
<section class="oumta-view" data-view="overview">
<div class="oumta-overview-grid">
<div class="oumta-card oumta-cluster-status" id="oumta-cluster-card">
<div class="oumta-card-label">Cluster state</div>
<div class="oumta-card-value" id="oumta-cluster-state">—</div>
<div class="oumta-card-sub" id="oumta-cluster-updated">no data</div>
</div>
<div class="oumta-card">
<div class="oumta-card-label">Workloads healthy</div>
<div class="oumta-card-value" id="oumta-workloads-up">—</div>
</div>
<div class="oumta-card">
<div class="oumta-card-label">Workloads down</div>
<div class="oumta-card-value oumta-danger" id="oumta-workloads-down">—</div>
</div>
<div class="oumta-card">
<div class="oumta-card-label">Open incidents</div>
<div class="oumta-card-value" id="oumta-incidents">—</div>
</div>
<div class="oumta-card oumta-card-wide">
<div class="oumta-card-label">Last successful import</div>
<div class="oumta-card-value oumta-card-value-sm" id="oumta-last-import">—</div>
</div>
</div>
<div class="oumta-section">
<div class="oumta-section-title">Workloads needing attention</div>
<div id="oumta-attention-list" class="oumta-attention-list">
<div class="oumta-empty">Nothing currently flagged.</div>
</div>
</div>
</section>
<!-- Workloads -->
<section class="oumta-view hidden" data-view="workloads">
<div class="oumta-toolbar">
<input id="oumta-wl-search" type="text" placeholder="Filter by name…" class="oumta-input">
<select id="oumta-wl-status" class="oumta-input">
<option value="">All states</option>
<option value="healthy">healthy</option>
<option value="degraded">degraded</option>
<option value="unavailable">unavailable</option>
</select>
<select id="oumta-wl-timerange" class="oumta-input">
<option value="1h">last 1h</option>
<option value="24h" selected>last 24h</option>
<option value="7d">last 7d</option>
<option value="">all</option>
</select>
</div>
<table class="oumta-table">
<thead>
<tr>
<th>Name</th>
<th>Namespace</th>
<th>State</th>
<th>Replicas</th>
<th>CPU</th>
<th>Memory</th>
<th>Image</th>
<th>Last reason</th>
</tr>
</thead>
<tbody id="oumta-wl-body">
<tr><td colspan="8" class="oumta-empty">No workloads.</td></tr>
</tbody>
</table>
</section>
<!-- Timeline (workload-detail opens here as well) -->
<section class="oumta-view hidden" data-view="timeline">
<div class="oumta-timeline-header">
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
</div>
<div id="oumta-timeline-body" class="oumta-timeline-body">
<div class="oumta-empty">No events.</div>
</div>
</section>
</div>
</div>
</div>
@@ -0,0 +1,532 @@
// ── Oumta Dashboard plugin ──
//
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
// Currently ALL data is mocked so we can iterate on layout without waiting
// for the backend. See TODO.md for every call that must later become a real
// fetch against the backend.
const h = window.hyprcrd;
const $ = id => document.getElementById(id);
// ── Settings / persisted state ──
//
// We use localStorage for now. Later we might persist via a hyprcrd setting
// or a plugin-level table — see TODO.md.
const LS_KEY = 'oumta-dashboard-settings';
const state = {
settings: {
baseUrl: '', // e.g. https://dashboard.oumta.internal
authToken: '', // Keycloak bearer; empty = no auth yet
pollIntervalSec: 15,
},
namespaces: [], // [{ id, label, namespace }]
activeNamespace: null,
activeView: 'overview',
liveMode: 'polling', // 'live' | 'polling' | 'error' | 'off'
pollHandle: null,
sseHandle: null,
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
};
function loadSettings() {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) Object.assign(state.settings, JSON.parse(raw));
} catch {}
}
function saveSettings() {
localStorage.setItem(LS_KEY, JSON.stringify(state.settings));
}
// ── Central API surface ─────────────────────────────────────────
//
// Every backend call routes through `api()`. Right now each case returns
// mock data. When the backend endpoints are ready, swap the `return MOCK…`
// lines for real `fetch(…)` calls. The UI code above this layer should
// not need to change.
//
// Field naming legend for the mocks below:
// [REAL] = already defined in Go code (internal/httpapi/router.go).
// Must match byte-for-byte when flipping to real fetch.
// [PROPOSED] = our guess based on docs/README.md prose. Backend author
// is free to rename — when they do, adjust the renderer
// (there's only one place per field) or add a small adapter
// inside api() that normalises the response.
const MOCK_NAMESPACES = [
{ id: 'oumta-dev', label: 'oumta-dev', namespace: 'oumta-dev' },
{ id: 'oumta-beta', label: 'oumta-beta', namespace: 'oumta-beta' },
];
// Overview mock — shape matches the existing Go struct `clusterOverviewResponse`
// byte-for-byte (see cmd/api/main.go → internal/httpapi/router.go).
// [REAL] clusterState, workloadsUp, workloadsDown, updatedAtUtc
// Anything else the overview page shows (incidents count, attention list) is
// DERIVED client-side from the workloads response — see renderOverview().
function mockOverview(ns) {
const wl = mockWorkloads(ns);
const up = wl.filter(w => w.state === 'healthy').length;
const down = wl.filter(w => w.state === 'unavailable').length;
const degraded = wl.filter(w => w.state === 'degraded').length;
const clusterState = down > 0 ? 'down' : (degraded > 0 ? 'degraded' : 'healthy');
return {
clusterState,
workloadsUp: up,
workloadsDown: down,
updatedAtUtc: new Date().toISOString(),
};
}
// Workloads mock — [PROPOSED] shape. Fields chosen from docs/README.md §2:
// Name, Namespace, Zustand, Replikas (ready/desired), CPU, Speicher,
// Image, Letzter Grund für Zustandswechsel.
// `workloadId` is our synthetic stable handle (kind/namespace/name) so the
// timeline can be linked back. If the backend emits a different handle
// format we'll adjust the link-builders.
function mockWorkloads(ns) {
const id = name => `deployment/${ns}/${name}`;
return [
{ workloadId: id('api'), name: 'api', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '420m', memory: '512Mi', image: 'oumta/api:1.4.2', lastReason: 'CrashLoopBackOff', lastTransitionAt: minutesAgo(12) },
{ workloadId: id('api-canary'), name: 'api-canary', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '140m', memory: '310Mi', image: 'oumta/api:1.4.3-rc2', lastReason: '—', lastTransitionAt: minutesAgo(180) },
{ workloadId: id('web'), name: 'web', namespace: ns, state: 'healthy', replicasReady: 2, replicasDesired: 2, cpu: '95m', memory: '256Mi', image: 'oumta/web:2.1.0', lastReason: '—', lastTransitionAt: minutesAgo(720) },
{ workloadId: id('worker'), name: 'worker', namespace: ns, state: 'healthy', replicasReady: 4, replicasDesired: 4, cpu: '820m', memory: '1Gi', image: 'oumta/worker:1.4.2', lastReason: '—', lastTransitionAt: minutesAgo(121) },
{ workloadId: id('ingest'), name: 'ingest', namespace: ns, state: 'unavailable', replicasReady: 0, replicasDesired: 1, cpu: '—', memory: '—', image: 'oumta/ingest:3.0.0', lastReason: 'ImagePullBackOff', lastTransitionAt: minutesAgo(3) },
{ workloadId: id('scheduler'), name: 'scheduler', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '55m', memory: '120Mi', image: 'oumta/scheduler:0.9.1', lastReason: '—', lastTransitionAt: minutesAgo(2880)},
{ workloadId: id('postgres'), name: 'postgres', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '180m', memory: '768Mi', image: 'postgres:16.2', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
{ workloadId: id('redis'), name: 'redis', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '25m', memory: '80Mi', image: 'redis:7.2-alpine', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
{ workloadId: id('nats'), name: 'nats', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '60m', memory: '200Mi', image: 'nats:2.10.14', lastReason: 'Readiness probe failed', lastTransitionAt: minutesAgo(45) },
{ workloadId: id('reports'), name: 'reports', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '75m', memory: '180Mi', image: 'oumta/reports:1.2.0', lastReason: '—', lastTransitionAt: minutesAgo(600) },
{ workloadId: id('notifier'), name: 'notifier', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '30m', memory: '96Mi', image: 'oumta/notifier:0.5.3', lastReason: '—', lastTransitionAt: minutesAgo(1500)},
{ workloadId: id('migrations'), name: 'migrations', namespace: ns, state: 'healthy', replicasReady: 0, replicasDesired: 0, cpu: '—', memory: '—', image: 'oumta/migrations:1.4.2', lastReason: 'Completed', lastTransitionAt: minutesAgo(300) },
];
}
// Timeline mock — [PROPOSED] shape.
// Fields inferred from docs §3 (Detail page) & §4 (filters): timestamp,
// severity, workloadId, message, reason. `kind` is a derived hint for
// rendering (deployment vs. incident vs. scale etc.) and can be dropped
// if the backend doesn't provide it.
function mockTimeline(ns, workloadId = null) {
const id = name => `deployment/${ns}/${name}`;
const all = [
{ timestamp: minutesAgo(3), severity: 'error', workloadId: id('ingest'), message: 'Pod ingest-6d8bf4 entered ImagePullBackOff', reason: 'ImagePullBackOff', kind: 'incident' },
{ timestamp: minutesAgo(8), severity: 'warning', workloadId: id('nats'), message: 'Replica 3/3 readiness probe failed', reason: 'ProbeFailed', kind: 'incident' },
{ timestamp: minutesAgo(12), severity: 'warning', workloadId: id('api'), message: 'Pod api-7c9ff6 restarted (3rd time in 10m)', reason: 'CrashLoopBackOff', kind: 'incident' },
{ timestamp: minutesAgo(26), severity: 'info', workloadId: id('api'), message: 'HorizontalPodAutoscaler targeted 3 replicas', reason: 'HPA', kind: 'scale' },
{ timestamp: minutesAgo(48), severity: 'info', workloadId: id('web'), message: 'Rollout completed to revision 17', reason: 'DeploymentRolled', kind: 'deployment' },
{ timestamp: minutesAgo(58), severity: 'info', workloadId: id('web'), message: 'Rollout started: oumta/web:2.1.0', reason: 'DeploymentStarted', kind: 'deployment' },
{ timestamp: minutesAgo(121), severity: 'info', workloadId: id('worker'), message: 'Scaled up from 2 to 4 replicas', reason: 'HPA', kind: 'scale' },
{ timestamp: minutesAgo(180), severity: 'info', workloadId: id('api-canary'), message: 'Canary became ready', reason: 'CanaryReady', kind: 'deployment' },
{ timestamp: minutesAgo(240), severity: 'info', workloadId: id('api'), message: 'Rollout started: oumta/api:1.4.2', reason: 'DeploymentStarted', kind: 'deployment' },
{ timestamp: minutesAgo(300), severity: 'info', workloadId: id('migrations'), message: 'Job completed successfully in 1m42s', reason: 'JobSucceeded', kind: 'deployment' },
{ timestamp: minutesAgo(720), severity: 'info', workloadId: id('reports'), message: 'Rolled out revision 9', reason: 'DeploymentRolled', kind: 'deployment' },
{ timestamp: minutesAgo(1500), severity: 'warning', workloadId: id('ingest'), message: 'OOMKilled: container exceeded 256Mi limit', reason: 'OOMKilled', kind: 'incident' },
{ timestamp: minutesAgo(2880), severity: 'info', workloadId: id('scheduler'), message: 'Initial rollout: oumta/scheduler:0.9.1', reason: 'DeploymentStarted', kind: 'deployment' },
];
return workloadId ? all.filter(e => e.workloadId === workloadId) : all;
}
function minutesAgo(m) { return new Date(Date.now() - m * 60_000).toISOString(); }
async function api(path, opts = {}) {
// TODO(api): when backend is ready, build a real fetch here.
// const url = new URL(path, state.settings.baseUrl);
// const headers = state.settings.authToken
// ? { Authorization: 'Bearer ' + state.settings.authToken } : {};
// const res = await fetch(url, { ...opts, headers });
// if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
// return res.json();
// Mock dispatch — keep in sync with backend `docs/README.md`.
const ns = state.activeNamespace?.namespace || '';
if (path === '/api/namespaces') return { items: MOCK_NAMESPACES }; // TODO(api)
if (path === '/api/cluster/overview') return mockOverview(ns); // TODO(api): GET /api/cluster/overview?namespace=
if (path === '/api/workloads') return { items: mockWorkloads(ns) }; // TODO(api): GET /api/workloads?namespace=&state=&since=
if (path.startsWith('/api/workloads/') && path.endsWith('/timeline')) {
// /api/workloads/{namespace}/{name}/timeline
const parts = path.replace('/api/workloads/', '').replace('/timeline', '').split('/');
const workloadId = `deployment/${parts[0]}/${parts[1]}`;
return { items: mockTimeline(ns, workloadId) }; // TODO(api)
}
if (path === '/api/timeline') return { items: mockTimeline(ns) }; // TODO(api): GET /api/timeline?namespace=
throw new Error('unknown api path: ' + path);
}
// ── Fullscreen overlay show/hide ─────────────────────────────────
const overlay = $('oumta-overlay');
function isOpen() {
return overlay?.classList.contains('open');
}
function show() {
if (!overlay) return;
overlay.classList.add('open');
if (!state.namespaces.length) bootstrap();
else {
refresh();
startLive();
}
}
function hide() {
if (!overlay) return;
overlay.classList.remove('open');
stopLive();
}
function toggle() {
isOpen() ? hide() : show();
}
window._oumtaToggle = toggle;
// ── Bootstrap: load namespaces, then first refresh ─────────────
async function bootstrap() {
try {
const res = await api('/api/namespaces');
state.namespaces = res.items || [];
state.activeNamespace = state.namespaces[0] || null;
renderNamespaceTabs();
await refresh();
startLive();
} catch (e) {
setLive('error', e.message);
}
}
function renderNamespaceTabs() {
const host = $('oumta-ns-tabs');
if (!host) return;
host.innerHTML = state.namespaces.map(n => `
<button class="oumta-ns-tab ${state.activeNamespace?.id === n.id ? 'active' : ''}" data-ns="${n.id}">
${n.label}
</button>
`).join('');
host.querySelectorAll('.oumta-ns-tab').forEach(btn => {
btn.addEventListener('click', () => {
const ns = state.namespaces.find(x => x.id === btn.dataset.ns);
if (ns && ns.id !== state.activeNamespace?.id) {
state.activeNamespace = ns;
renderNamespaceTabs();
refresh();
}
});
});
}
// ── View switching ────────────────────────────────────────────
document.addEventListener('click', e => {
const tab = e.target.closest('.oumta-view-tab');
if (!tab || !overlay.contains(tab)) return;
switchView(tab.dataset.view);
});
function switchView(view) {
state.activeView = view;
document.querySelectorAll('.oumta-view-tab').forEach(b =>
b.classList.toggle('active', b.dataset.view === view));
document.querySelectorAll('.oumta-view').forEach(s =>
s.classList.toggle('hidden', s.dataset.view !== view));
// Reset workload-scoped timeline when leaving timeline view
if (view !== 'timeline') state.data.scopeWorkload = null;
renderTimeline();
}
// ── Refresh all data for the active namespace ──────────────────
async function refresh() {
if (!state.activeNamespace) return;
try {
const [ov, wl, tl] = await Promise.all([
api('/api/cluster/overview'),
api('/api/workloads'),
api('/api/timeline'),
]);
state.data.overview = ov;
state.data.workloads = wl.items || [];
state.data.timeline = tl.items || [];
renderAll();
} catch (e) {
setLive('error', e.message);
}
}
// ── Renderers ─────────────────────────────────────────────────
function renderAll() {
renderOverview();
renderWorkloads();
renderTimeline();
const genAt = state.data.overview?.updatedAtUtc;
const el = $('oumta-generated-at');
if (el && genAt) el.textContent = 'last import: ' + formatTime(genAt);
}
function renderOverview() {
const o = state.data.overview;
const wl = state.data.workloads || [];
if (!o) return;
// Values that come directly from the Go overview endpoint [REAL].
$('oumta-cluster-card').dataset.state = o.clusterState || 'unknown';
$('oumta-cluster-state').textContent = o.clusterState || 'unknown';
$('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data';
$('oumta-workloads-up').textContent = o.workloadsUp ?? '—';
$('oumta-workloads-down').textContent = o.workloadsDown ?? '—';
$('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—';
// Values derived client-side from the workloads list — the overview
// endpoint doesn't carry these today. If the backend later adds
// `incidents` / `attention` fields, prefer those over the derivation.
const attention = wl.filter(w => w.state !== 'healthy')
.sort((a, b) => rankState(a.state) - rankState(b.state));
$('oumta-incidents').textContent = attention.length || '—';
const list = $('oumta-attention-list');
if (!attention.length) {
list.innerHTML = '<div class="oumta-empty">Nothing currently flagged.</div>';
return;
}
list.innerHTML = attention.map(a => `
<div class="oumta-attention-row" data-wlid="${escapeAttr(a.workloadId)}">
<span class="oumta-attention-state" data-state="${a.state}">${a.state}</span>
<span class="oumta-attention-name">${escapeHtml(a.name)}</span>
<span class="oumta-attention-ns">${escapeHtml(a.namespace)}</span>
<span class="oumta-attention-reason">${escapeHtml(a.lastReason || '')}</span>
</div>
`).join('');
list.querySelectorAll('.oumta-attention-row').forEach(row => {
row.addEventListener('click', () => openWorkload(row.dataset.wlid));
});
}
// Severity order for the attention list: worst first.
function rankState(s) {
return { unavailable: 0, degraded: 1, healthy: 2 }[s] ?? 9;
}
function renderWorkloads() {
const search = ($('oumta-wl-search').value || '').toLowerCase();
const filterState = $('oumta-wl-status').value;
const body = $('oumta-wl-body');
const rows = (state.data.workloads || []).filter(w => {
if (search && !w.name.toLowerCase().includes(search)) return false;
if (filterState && w.state !== filterState) return false;
return true;
});
if (!rows.length) {
body.innerHTML = '<tr><td colspan="8" class="oumta-empty">No workloads match.</td></tr>';
return;
}
body.innerHTML = rows.map(w => `
<tr data-wlid="${escapeAttr(w.workloadId)}">
<td>${escapeHtml(w.name)}</td>
<td>${escapeHtml(w.namespace)}</td>
<td class="wl-state-${w.state}">${w.state}</td>
<td>${w.replicasReady}/${w.replicasDesired}</td>
<td>${escapeHtml(w.cpu)}</td>
<td>${escapeHtml(w.memory)}</td>
<td>${escapeHtml(w.image)}</td>
<td>${escapeHtml(w.lastReason)}</td>
</tr>
`).join('');
body.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => openWorkload(tr.dataset.wlid));
});
}
function renderTimeline() {
const scope = state.data.scopeWorkload;
const scopeEl = $('oumta-timeline-scope');
const backEl = $('oumta-timeline-back');
if (scope) {
scopeEl.textContent = 'events for ' + scope;
backEl.classList.remove('hidden');
} else {
scopeEl.textContent = 'all events';
backEl.classList.add('hidden');
}
const events = scope
? (state.data.timeline.filter(e => e.workloadId === scope))
: state.data.timeline;
const body = $('oumta-timeline-body');
if (!events.length) {
body.innerHTML = '<div class="oumta-empty">No events.</div>';
return;
}
body.innerHTML = events.map(e => `
<div class="oumta-timeline-entry">
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
<div class="oumta-timeline-severity" data-sev="${e.severity}">${e.severity}</div>
<div>
<div class="oumta-timeline-message">${escapeHtml(e.message)}</div>
<div class="oumta-timeline-meta">${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}</div>
</div>
</div>
`).join('');
}
// ── Workload → detail-as-timeline navigation ──────────────────
async function openWorkload(workloadId) {
state.data.scopeWorkload = workloadId;
const [nsSlug, name] = workloadId.replace('deployment/', '').split('/');
// Pull workload-scoped timeline — this REPLACES the generic one for rendering.
try {
const res = await api(`/api/workloads/${nsSlug}/${name}/timeline`);
state.data.timeline = res.items || [];
} catch (e) { setLive('error', e.message); }
switchView('timeline');
}
$('oumta-timeline-back')?.addEventListener('click', () => {
state.data.scopeWorkload = null;
// Restore the global timeline
api('/api/timeline').then(r => {
state.data.timeline = r.items || [];
renderTimeline();
}).catch(e => setLive('error', e.message));
});
// ── Realtime (SSE) with polling fallback ──────────────────────
//
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
// not implemented yet, so we go straight to polling. When SSE is available
// we can try to connect and fall back on `error`.
function startLive() {
stopLive();
// TODO(api): attempt SSE first:
// state.sseHandle = new EventSource(url);
// state.sseHandle.onmessage = ev => applyLiveEvent(JSON.parse(ev.data));
// state.sseHandle.onerror = () => { stopLive(); startPolling(); setLive('polling'); };
// state.sseHandle.onopen = () => setLive('live');
startPolling();
}
function stopLive() {
if (state.pollHandle) { clearInterval(state.pollHandle); state.pollHandle = null; }
if (state.sseHandle) { state.sseHandle.close(); state.sseHandle = null; }
setLive('off');
}
function startPolling() {
const ms = (state.settings.pollIntervalSec || 15) * 1000;
state.pollHandle = setInterval(refresh, ms);
setLive('polling');
}
function setLive(mode, detail = '') {
state.liveMode = mode;
const el = $('oumta-live-indicator');
const lbl = $('oumta-live-label');
if (el) el.dataset.state = mode;
if (lbl) {
lbl.textContent = ({
live: 'live',
polling: 'polling',
error: 'error' + (detail ? ' — ' + detail : ''),
off: 'off',
})[mode] || mode;
}
}
// ── Header buttons ───────────────────────────────────────────
$('oumta-close')?.addEventListener('click', hide);
$('oumta-refresh')?.addEventListener('click', refresh);
$('oumta-wl-search')?.addEventListener('input', renderWorkloads);
$('oumta-wl-status')?.addEventListener('change', renderWorkloads);
$('oumta-wl-timerange')?.addEventListener('change', () => {
// TODO(api): forward to /api/workloads?since=<ISO> once the backend accepts it.
renderWorkloads();
});
// ESC to close
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && isOpen()) {
e.preventDefault(); e.stopPropagation();
hide();
}
}, { capture: true });
// ── Inject header button into hyprcrd top bar ────────────────
function injectHeaderButton() {
const tasksBtn = document.getElementById('tasks-btn');
if (!tasksBtn || document.getElementById('oumta-btn')) return;
const btn = document.createElement('button');
btn.id = 'oumta-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-gauge text-sm"></i><span class="font-pixel text-[10px]">Oumta</span>';
btn.addEventListener('click', toggle);
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectHeaderButton);
} else {
injectHeaderButton();
}
// ── Settings tab in hyprcrd settings ─────────────────────────
function renderSettingsPanel(panelEl) {
panelEl.innerHTML = `
<div class="oumta-settings-field">
<label for="oumta-set-baseurl">API Base URL</label>
<input type="text" id="oumta-set-baseurl" placeholder="http://localhost:8080">
<div class="oumta-settings-help">Where the Oumta Go backend is reachable from this browser. No trailing slash.</div>
</div>
<div class="oumta-settings-field">
<label for="oumta-set-token">Auth token (optional)</label>
<input type="text" id="oumta-set-token" placeholder="Bearer token from Keycloak">
<div class="oumta-settings-help">Will be sent as <code>Authorization: Bearer &lt;token&gt;</code>. Leave empty if the API is unauthenticated.</div>
</div>
<div class="oumta-settings-field">
<label for="oumta-set-poll">Poll interval (seconds)</label>
<input type="number" id="oumta-set-poll" min="5" max="300">
<div class="oumta-settings-help">Used as a fallback when SSE is unavailable.</div>
</div>
<button id="oumta-set-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors">
<i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span>
</button>
<span id="oumta-set-status" class="font-pixel text-[10px] text-t-dim ml-3"></span>
`;
$('oumta-set-baseurl').value = state.settings.baseUrl || '';
$('oumta-set-token').value = state.settings.authToken || '';
$('oumta-set-poll').value = state.settings.pollIntervalSec || 15;
$('oumta-set-save').addEventListener('click', () => {
state.settings.baseUrl = $('oumta-set-baseurl').value.trim();
state.settings.authToken = $('oumta-set-token').value.trim();
state.settings.pollIntervalSec = Math.max(5, parseInt($('oumta-set-poll').value) || 15);
saveSettings();
const s = $('oumta-set-status');
s.textContent = 'saved';
setTimeout(() => { s.textContent = ''; }, 1500);
// If the overlay is open, restart polling with new interval.
if (isOpen()) { stopLive(); startLive(); }
});
}
// ── Helpers ─────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeAttr(s) { return escapeHtml(s); }
function formatTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString();
} catch { return iso; }
}
// ── Boot ────────────────────────────────────────────────────
loadSettings();
h?.settings?.registerTab?.({
id: 'oumta-dashboard',
label: 'oumta',
icon: 'ph-gauge',
onShow: renderSettingsPanel,
});
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });
@@ -0,0 +1,10 @@
{
"name": "oumta-dashboard",
"version": "0.1.0",
"description": "Oumta cluster dashboard — overview, workloads, timeline.",
"frontend": {
"js": ["oumta-dashboard.js"],
"css": ["oumta-dashboard.css"],
"html": ["oumta-dashboard.html"]
}
}
+3 -3
View File
@@ -28,11 +28,11 @@
<span class="font-pixel text-[10px]">image:</span>
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
</label>
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button>
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" data-tip="pick from gallery"><i class="ph ph-images text-sm"></i></button>
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" data-tip="upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
<div class="w-px h-4 bg-t-border mx-1"></div>
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" data-tip="delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
<div class="flex-1"></div>
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
</div>
+1 -1
View File
@@ -187,7 +187,7 @@ function renderImageSlide(slide) {
const rects = slide.hotspots.map((h, i) => {
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
const label = resolveTargetLabel(h.target) || h.target || '';
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" title="${escHtml(label)}"></div>`;
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" data-tip="${escHtml(label)}"></div>`;
}).join('');
viewerSlide.innerHTML = `
<div class="slide-image-wrap">
+48
View File
@@ -0,0 +1,48 @@
# terminal plugin
In-app command line for controlling hyprcrd with text commands.
## Toggle
**Shift+T** opens/closes the terminal. **ESC** closes it from inside.
## Commands
| Command | Description |
|---------|-------------|
| `help` | list all commands |
| `clear` | clear terminal output |
| `go <bucket>` | switch bucket (name or: all, current, favorites, journal, unsorted) |
| `open <card>` | open card preview (fuzzy title match) |
| `edit <card>` | open card in editor |
| `new <title>` | create card in active bucket |
| `delete <card>` | delete a card |
| `tag <card> <tag>` | append tag to card body |
| `untag <card> <tag>` | remove tag from card body |
| `color <card> <hex>` | change card color |
| `move <card> <bucket>` | move card to another bucket |
| `search <query>` | open search overlay with query |
| `find <text>` | list matching cards inline |
| `calendar` | open calendar |
| `assets` | open assets gallery |
| `export` | download export ZIP |
| `sort <mode>` | change sort (manual/newest/oldest/alpha/tag) |
| `split` | toggle split preview |
| `zen` | toggle zen mode |
| `count` | show card and bucket counts |
| `tags` | list all tags |
| `buckets` | list all buckets |
| `version` | show app version |
## Features
- **Tab completion** on command names
- **History** with arrow up/down
- **Fuzzy matching** on card and bucket names
- Colored output: accent for success, red for errors, grey for info
- Slides up from the bottom, max 45% viewport height
- Scrollable output buffer
## Disabling
Add `"terminal"` to the `disabled` array in `data/plugins.json`.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "terminal",
"version": "1.0.0",
"description": "In-app terminal for controlling hyprcrd with text commands. Toggle with Shift+T.",
"frontend": {
"js": ["terminal.js"],
"css": ["terminal.css"],
"html": ["terminal.html"]
}
}
+39
View File
@@ -0,0 +1,39 @@
.hterm {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 500;
background: var(--t-bg); border-top: 1px solid var(--t-border);
max-height: 45vh; display: flex; flex-direction: column;
box-shadow: 0 -8px 32px rgba(0,0,0,0.4);
transform: translateY(0);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.hterm.hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }
#hterm-output {
flex: 1; overflow-y: auto; padding: 12px 16px;
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
font-size: 12px; line-height: 1.7;
scrollbar-width: thin;
}
.hterm-line { color: var(--t-text); white-space: pre-wrap; word-break: break-word; }
.hterm-line.cmd { color: var(--t-bright); }
.hterm-line.cmd::before { content: '> '; color: var(--t-accent); }
.hterm-line.ok { color: var(--t-accent); }
.hterm-line.err { color: var(--t-danger); }
.hterm-line.dim { color: var(--t-dim); }
.hterm-line.heading { color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; margin-top: 8px; }
.hterm-input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; border-top: 1px solid var(--t-border);
background: var(--t-surface);
}
.hterm-prompt {
color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 11px; flex-shrink: 0;
}
#hterm-input {
flex: 1; background: transparent; border: none; color: var(--t-bright);
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
font-size: 12px; outline: none;
}
#hterm-input::placeholder { color: var(--t-dim); }
+7
View File
@@ -0,0 +1,7 @@
<div id="hterm" class="hterm hidden">
<div id="hterm-output"></div>
<div class="hterm-input-row">
<span class="hterm-prompt">></span>
<input type="text" id="hterm-input" autocomplete="off" spellcheck="false" placeholder="type a command...">
</div>
</div>
+376
View File
@@ -0,0 +1,376 @@
// ── Terminal plugin — in-app command line ──
const h = window.hyprcrd;
const $ = id => document.getElementById(id);
const panel = $('hterm');
const output = $('hterm-output');
const input = $('hterm-input');
let visible = false;
let history = [];
let historyIdx = -1;
function toggle() {
visible = !visible;
panel.classList.toggle('hidden', !visible);
if (visible) { input.focus(); scrollBottom(); }
else { document.activeElement?.blur(); }
}
function scrollBottom() {
output.scrollTop = output.scrollHeight;
}
function print(text, cls = '') {
const line = document.createElement('div');
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
line.textContent = text;
output.appendChild(line);
scrollBottom();
}
function printHtml(html, cls = '') {
const line = document.createElement('div');
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
line.innerHTML = html;
output.appendChild(line);
scrollBottom();
}
// ── API helpers ──
async function api(path, opts = {}) {
return h?.api?.(path, opts) || fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(r => r.json());
}
function fuzzy(needle, haystack) {
const n = needle.toLowerCase();
return haystack.filter(s => s.toLowerCase().includes(n));
}
function findCard(query) {
const q = query.toLowerCase();
return window.state?.cards?.find(c => c.title.toLowerCase() === q)
|| window.state?.cards?.find(c => c.title.toLowerCase().includes(q));
}
function findBucket(query) {
const q = query.toLowerCase();
return window.state?.buckets?.find(b => b.name.toLowerCase() === q)
|| window.state?.buckets?.find(b => b.name.toLowerCase().includes(q));
}
// ── Commands ──
const COMMANDS = {};
function register(name, desc, fn) {
COMMANDS[name] = { desc, fn };
}
register('help', 'show available commands', () => {
print('COMMANDS', 'heading');
const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
for (const [name, cmd] of Object.entries(COMMANDS)) {
print(` ${name.padEnd(maxLen + 2)} ${cmd.desc}`, 'dim');
}
});
register('clear', 'clear terminal output', () => {
output.innerHTML = '';
});
register('go', 'go <bucket> — switch to bucket', (args) => {
const name = args.join(' ');
if (!name) { print('usage: go <bucket name>', 'err'); return; }
const specials = { all: 'all', current: 'current', favorites: 'favorites', journal: 'journal', unsorted: 'none' };
if (specials[name.toLowerCase()]) {
window.state.activeBucket = specials[name.toLowerCase()];
window._renderBoard?.();
print(`switched to ${name}`, 'ok');
return;
}
const b = findBucket(name);
if (!b) { print(`bucket not found: ${name}`, 'err'); return; }
window.state.activeBucket = b.id;
window._renderBoard?.();
print(`switched to ${b.name}`, 'ok');
});
register('open', 'open <card> — open card preview', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: open <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
window._openDetail?.(c.id);
print(`opened: ${c.title}`, 'ok');
});
register('edit', 'edit <card> — open card in editor', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: edit <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
window._openModal?.(c);
print(`editing: ${c.title}`, 'ok');
});
register('new', 'new <title> — create card in active bucket', async (args) => {
const title = args.join(' ');
if (!title) { print('usage: new <title>', 'err'); return; }
const bucketId = window.state?.activeBucket;
const numBucket = Number.isFinite(Number(bucketId)) ? Number(bucketId) : null;
const res = await api('api/cards', { method: 'POST', body: { title, body: '', color: '#38bdf8', bucket_id: numBucket } });
if (res?.id) {
print(`created: ${title} (id: ${res.id})`, 'ok');
await window._reloadAll?.();
} else {
print(res?.error || 'failed', 'err');
}
});
register('delete', 'delete <card> — delete a card', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: delete <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'DELETE' });
print(`deleted: ${c.title}`, 'ok');
await window._reloadAll?.();
});
register('tag', 'tag <card> <tag> — add tag to card', async (args) => {
if (args.length < 2) { print('usage: tag <card title> <tag>', 'err'); return; }
const tag = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const body = (c.body || '') + `\n#${tag}`;
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
print(`tagged ${c.title} with #${tag}`, 'ok');
await window._reloadAll?.();
});
register('untag', 'untag <card> <tag> — remove tag from card', async (args) => {
if (args.length < 2) { print('usage: untag <card title> <tag>', 'err'); return; }
const tag = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const body = (c.body || '').replace(new RegExp(`\\s*#${tag}\\b`, 'g'), '');
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
print(`removed #${tag} from ${c.title}`, 'ok');
await window._reloadAll?.();
});
register('color', 'color <card> <hex> — change card color', async (args) => {
if (args.length < 2) { print('usage: color <card title> <#hex>', 'err'); return; }
const hex = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { color: hex } });
print(`${c.title} → ${hex}`, 'ok');
await window._reloadAll?.();
});
register('move', 'move <card> <bucket> — move card to bucket', async (args) => {
if (args.length < 2) { print('usage: move <card title> <bucket name>', 'err'); return; }
const bucketName = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const b = findBucket(bucketName);
if (!b) { print(`bucket not found: ${bucketName}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { bucket_id: b.id } });
print(`moved ${c.title} → ${b.name}`, 'ok');
await window._reloadAll?.();
});
register('search', 'search <query> — open search with query', (args) => {
const q = args.join(' ');
window._closeSearch?.();
const searchInput = document.getElementById('search-input');
const overlay = document.getElementById('search');
if (overlay) overlay.classList.add('open');
if (searchInput) { searchInput.value = q; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }
print(`searching: ${q}`, 'ok');
});
register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); });
register('tim', 'open time calculator', () => {
if (window._timToggle) { window._timToggle(); print('tim opened', 'ok'); }
else print('tim plugin not loaded', 'err');
});
register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
register('export', 'download export ZIP', () => {
document.getElementById('export-btn')?.click();
print('export started', 'ok');
});
register('sort', 'sort <mode> — manual/newest/oldest/alpha/tag', (args) => {
const mode = args[0];
if (!mode) { print('usage: sort <manual|newest|oldest|alpha|tag>', 'err'); return; }
const sortBtn = document.getElementById('sort-toggle');
if (sortBtn) {
const label = document.getElementById('sort-label');
if (label) label.textContent = mode;
window.state.sortMode = mode;
window._renderBoard?.();
print(`sort: ${mode}`, 'ok');
}
});
register('split', 'toggle split preview', () => {
document.getElementById('board-split-toggle')?.click();
print('split toggled', 'ok');
});
register('zen', 'toggle zen mode on current detail', () => {
document.getElementById('detail-zen')?.click();
print('zen toggled', 'ok');
});
register('count', 'show card and bucket counts', () => {
const cards = window.state?.cards?.length || 0;
const buckets = window.state?.buckets?.length || 0;
print(`${cards} cards · ${buckets} buckets`, 'ok');
});
register('tags', 'list all tags', () => {
const tagSet = new Set();
for (const c of window.state?.cards || []) {
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
}
if (!tagSet.size) { print('no tags', 'dim'); return; }
const sorted = [...tagSet].sort();
print(`${sorted.length} tags:`, 'heading');
print(sorted.map(t => '#' + t).join(' '));
});
register('buckets', 'list all buckets', () => {
const bs = window.state?.buckets || [];
if (!bs.length) { print('no buckets', 'dim'); return; }
print(`${bs.length} buckets:`, 'heading');
for (const b of bs) print(` ${b.name} (${b.id})${b.parent_id ? ` → child of ${b.parent_id}` : ''}`);
});
register('history', 'history <card> — show edit history', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: history <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
let entries;
try {
const res = await fetch(`api/cards/${c.id}/history`);
entries = await res.json();
} catch (e) { print('failed to fetch history', 'err'); return; }
if (!entries || !entries.length) { print(`${c.title}: no edit history`, 'dim'); return; }
print(`${c.title} — ${entries.length} changes:`, 'heading');
for (const e of entries) {
const old = (e.old_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
const nw = (e.new_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
const time = e.changed_at?.substring(0, 16) || '?';
print(` ${time} ${e.field}: ${old} → ${nw}`, 'dim');
}
});
register('find', 'find <text> — list matching cards', (args) => {
const q = args.join(' ').toLowerCase();
if (!q) { print('usage: find <text>', 'err'); return; }
const hits = (window.state?.cards || []).filter(c =>
c.title.toLowerCase().includes(q) || (c.body || '').toLowerCase().includes(q) || (c.tags || '').toLowerCase().includes(q)
);
if (!hits.length) { print('no matches', 'dim'); return; }
print(`${hits.length} matches:`, 'heading');
for (const c of hits.slice(0, 20)) {
print(` ${c.title}${c.tags ? ' ' + (c.tags || '').split(',').map(t => '#' + t).join(' ') : ''}`, 'dim');
}
if (hits.length > 20) print(` ... and ${hits.length - 20} more`, 'dim');
});
register('version', 'show app version', () => {
const v = document.querySelector('[data-app-version]')?.textContent || '?';
print(v, 'ok');
});
// ── Tab completion ──
function tabComplete() {
const val = input.value;
const parts = val.split(/\s+/);
if (parts.length <= 1) {
const matches = fuzzy(parts[0] || '', Object.keys(COMMANDS));
if (matches.length === 1) { input.value = matches[0] + ' '; }
else if (matches.length > 1) { print(matches.join(' '), 'dim'); }
}
}
// ── Execute ──
async function exec(line) {
const trimmed = line.trim();
if (!trimmed) return;
history.push(trimmed);
historyIdx = history.length;
print(trimmed, 'cmd');
const parts = trimmed.split(/\s+/);
const cmd = parts.shift().toLowerCase();
const command = COMMANDS[cmd];
if (!command) {
print(`unknown command: ${cmd}. Type "help" for a list.`, 'err');
return;
}
try {
await command.fn(parts);
} catch (e) {
print(`error: ${e.message}`, 'err');
}
}
// ── Event handlers ──
input?.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const val = input.value;
input.value = '';
exec(val);
} else if (e.key === 'Tab') {
e.preventDefault();
tabComplete();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIdx > 0) { historyIdx--; input.value = history[historyIdx]; }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; }
else { historyIdx = history.length; input.value = ''; }
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
toggle();
}
});
// Shift+T toggle (capture, before shortcuts)
window.addEventListener('keydown', e => {
if (e.key === 'T' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
toggle();
}
}, { capture: true });
print('hyprcrd terminal — type "help" for commands, Shift+T to toggle', 'dim');
h?.registerPlugin({ name: 'terminal', version: '1.0.0' });
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=4';
async function openEditor() {
const card = document.querySelector('#board [data-id]');
if (!card) return false;
card.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
for (let i = 0; i < 20; i++) {
if ($('#modal')?.classList.contains('open')) return true;
await sleep(100);
}
return false;
}
async function openPicker() {
click('#md-asset-picker');
for (let i = 0; i < 30; i++) {
if (document.getElementById('asset-picker-flyout')) return true;
await sleep(100);
}
return false;
}
describe('Asset Picker', () => {
afterEach(async () => {
document.getElementById('asset-picker-flyout')?.remove();
await closeAll();
});
it('toolbar button exists', () => {
expect($('#md-asset-picker')).toExist();
});
it('opens flyout with search and grid, toggles closed on second click', async () => {
if (!await openEditor()) return;
if (!await openPicker()) return;
expect(document.getElementById('asset-picker-flyout')).toExist();
expect(document.getElementById('asset-picker-search')).toExist();
expect(document.getElementById('asset-picker-grid')).toExist();
// Second click closes
click('#md-asset-picker');
for (let i = 0; i < 20; i++) {
if (!document.getElementById('asset-picker-flyout')) break;
await sleep(50);
}
expect(document.getElementById('asset-picker-flyout')).toBe(null);
});
it('search input filters tiles', async () => {
if (!await openEditor()) return;
if (!await openPicker()) return;
const search = document.getElementById('asset-picker-search');
if (!search) return;
search.value = 'zzz_nonexistent_zzz';
search.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(50);
const grid = document.getElementById('asset-picker-grid');
expect(grid.querySelectorAll('.asset-pick-tile').length).toBe(0);
});
});
+22 -1
View File
@@ -47,7 +47,7 @@ describe('Calendar', () => {
await sleep(200);
click('#cal-today');
await sleep(200);
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]);
});
@@ -58,4 +58,25 @@ describe('Calendar', () => {
await sleep(150);
expect($('#calendar')).toBeHidden();
});
it('preserves month across close/reopen', async () => {
// Open calendar and navigate forward 3 months
click('#calendar-btn');
await sleep(200);
click('#cal-next');
click('#cal-next');
click('#cal-next');
await sleep(200);
const navigatedTitle = $('#cal-title').textContent;
// Close
click('#cal-close');
await sleep(150);
expect($('#calendar')).toBeHidden();
// Reopen — must stay on the navigated month, not jump to today
click('#calendar-btn');
await sleep(200);
expect($('#cal-title').textContent).toBe(navigatedTitle);
});
});
+33 -9
View File
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
click(doneBtn);
await sleep(300);
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
// Poll until #done is persisted to the DB
let updated;
for (let i = 0; i < 40; i++) {
await sleep(100);
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
if ((updated[0]?.tags || '').includes('done')) break;
}
expect((updated[0].body || '').toLowerCase()).toContain('#done');
expect(updated[0].tags || '').toContain('done');
// Wait for the board to re-render with the updated card data
for (let i = 0; i < 20; i++) {
const card = window.state?.cards?.find(c => c.id == res.id);
if (card && (card.tags || '').includes('done')) break;
await sleep(100);
}
// Re-open menu — the entry should now flip to "Unmark done"
rightClick(document.querySelector(`[data-id="${res.id}"]`));
await sleep(150);
await sleep(200);
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
// Confirm the delete
const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
await sleep(500);
}
// All three cards should be gone
const remaining = await fetch('api/cards').then(r => r.json());
// Poll until all three cards are gone (group delete = N serial DELETEs)
let remaining;
for (let i = 0; i < 40; i++) {
await sleep(100);
remaining = await fetch('api/cards').then(r => r.json());
const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id);
if (!stillThere) break;
}
expect(remaining.some(c => c.id === parent.id)).toBe(false);
expect(remaining.some(c => c.id === child1.id)).toBe(false);
expect(remaining.some(c => c.id === child2.id)).toBe(false);
@@ -375,11 +392,18 @@ describe('Card: Group Delete', () => {
if (dialog?.classList.contains('open')) {
const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
await sleep(500);
}
// Confirm both are gone
let cards = await fetch('api/cards').then(r => r.json());
// Poll until deletion lands (can take a while on slow machines with
// confirm-dialog + two DELETE calls chained).
let cards;
const start = Date.now();
while (Date.now() - start < 4000) {
cards = await fetch('api/cards').then(r => r.json());
const stillThere = cards.some(c => c.id === parent.id || c.id === child.id);
if (!stillThere) break;
await sleep(100);
}
expect(cards.some(c => c.id === parent.id)).toBe(false);
expect(cards.some(c => c.id === child.id)).toBe(false);
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, click, $, sleep, waitFor } from './runner.js?v=4';
// Chat plugin tests — UI-focused. No send/receive here: that would
// require a real relay, and the user's "no mocks" rule means we don't
// fake one. Runs against the real /api/chat endpoints, which respond
// cleanly on a fresh install (empty conversation list).
const ensureFlyoutClosed = async () => {
const f = $('#chat-flyout');
if (!f) return;
if (!f.classList.contains('hidden')) {
click('#chat-close-btn');
await waitFor(() => f.classList.contains('hidden'));
}
};
describe('Chat: flyout', () => {
it('footer button is injected', () => {
expect($('#chat-footer-btn')).toExist();
});
it('toggles open and closed', async () => {
await ensureFlyoutClosed();
const f = $('#chat-flyout');
click('#chat-footer-btn');
await waitFor(() => !f.classList.contains('hidden'));
expect(f.classList.contains('hidden')).toBe(false);
click('#chat-close-btn');
await waitFor(() => f.classList.contains('hidden'));
expect(f.classList.contains('hidden')).toBe(true);
});
it('header-back visibility matches active view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
// Wait for loadConversations + initial view pick to settle.
await sleep(150);
const back = $('#chat-header-back');
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
const listShown = !$('#chat-list-view').classList.contains('hidden');
// Exactly one of thread / list should be active here (pick view
// is only reached via the + button).
expect(threadShown || listShown).toBe(true);
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
it('new-conversation button opens pick view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
await sleep(100);
click('#chat-new-btn');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
// Header back is only for thread view — not needed when pick has
// its own dedicated back button.
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
click('#chat-pick-back');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
});
describe('Chat: thread DOM structure', () => {
it('peer-strip element lives inside thread view', () => {
const strip = $('#chat-thread-peers');
expect(strip).toExist();
expect(strip.closest('#chat-thread-view')).toExist();
});
it('thread form, input and send button exist', () => {
expect($('#chat-thread-form')).toExist();
expect($('#chat-thread-input')).toExist();
expect($('#chat-thread-send')).toExist();
});
});
describe('Chat: API', () => {
it('/api/chat/conversations returns expected shape', async () => {
const res = await fetch('api/chat/conversations').then(r => r.json());
expect(Array.isArray(res.conversations)).toBe(true);
expect(typeof res.total_unread).toBe('number');
});
it('/api/chat/messages without peer returns 400', async () => {
const r = await fetch('api/chat/messages');
expect(r.status).toBe(400);
});
it('/api/chat/messages with unknown peer returns empty array', async () => {
const res = await fetch('api/chat/messages?peer=__test_no_such_peer__')
.then(r => r.json());
expect(Array.isArray(res.messages)).toBe(true);
expect(res.messages.length).toBe(0);
});
});
-2
View File
@@ -14,8 +14,6 @@ describe('RRULE: Data Model', () => {
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
for (const b of birthdays) {
expect(b.tags).toContain('geburtstag');
// Birthday titles start with date
expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/);
}
});
+128 -3
View File
@@ -5,7 +5,7 @@
// Backup is created before and auto-restored after every run.
import { getCsrf } from '../../js/modules/util.js';
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/sync.js';
// Suites live on window so two runner.js instances (one cached and loaded
// by test files via bare imports, one fresh and loaded by loader.js with
@@ -170,7 +170,73 @@ export function csrf() {
return getCsrf();
}
// Snapshot the view-affecting bits of window.state so we can force a
// neutral baseline (normal board, no filter, manual sort, no hides)
// for the test run and restore the user's view afterwards. Tests were
// flaky when the runner fired while the board was in super-view: DOM
// selectors like `.card[data-id]` don't match `.superview-item`, so
// multi-select and due-date tests read wrong initial values.
export function snapshotViewState() {
const s = window.state;
if (!s) return null;
return {
superView: s.superView,
activeBucket: s.activeBucket,
activeTags: [...(s.activeTags || [])],
activeGroup: s.activeGroup,
sortMode: s.sortMode,
hideTermine: s.hideTermine,
hideDone: s.hideDone,
hideTagged: s.hideTagged,
boardSplitOpen: s.boardSplitOpen,
};
}
export function resetViewState() {
const s = window.state;
if (!s) return;
s.superView = false;
s.activeBucket = 'all';
s.activeTags = [];
s.activeGroup = null;
s.sortMode = 'manual';
s.hideTermine = false;
s.hideDone = false;
s.hideTagged = false;
s.boardSplitOpen = false;
s.selectedIds?.clear?.();
// Sync super-view toggle visuals with the reset state so tests that
// inspect the button see the correct pose.
const btn = document.getElementById('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-list text-sm';
btn?.classList.remove('active');
window._renderBoard?.();
}
export function restoreViewState(snap) {
const s = window.state;
if (!s || !snap) return;
Object.assign(s, {
superView: snap.superView,
activeBucket: snap.activeBucket,
activeTags: snap.activeTags,
activeGroup: snap.activeGroup,
sortMode: snap.sortMode,
hideTermine: snap.hideTermine,
hideDone: snap.hideDone,
hideTagged: snap.hideTagged,
boardSplitOpen: snap.boardSplitOpen,
});
const btn = document.getElementById('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = `ph ${snap.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
btn?.classList.toggle('active', !!snap.superView);
window._renderBoard?.();
}
export async function closeAll() {
document.getElementById('hterm')?.classList.add('hidden');
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) el.classList.remove('open');
@@ -180,6 +246,7 @@ export async function closeAll() {
document.querySelector('.bucket-picker')?.remove();
document.querySelector('.orphan-lightbox')?.remove();
document.getElementById('bucket-super-popover')?.remove();
document.getElementById('asset-picker-flyout')?.remove();
document.getElementById('gitea-overlay')?.classList.remove('open');
// Clear multi-select
document.querySelectorAll('.card-selected').forEach(el => {
@@ -199,6 +266,37 @@ async function createBackup() {
backupBlob = await res.blob();
}
// Purge leftover test artifacts from previous failed runs.
// Tests use `__prefix__` naming; anything matching gets deleted before
// the baseline backup so it doesn't get restored after the run either.
async function purgeTestLeftovers() {
const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf();
let purged = { cards: 0, buckets: 0 };
try {
const cards = await fetch('api/cards').then(r => r.json());
const testCards = cards.filter(c => /^__.*__/.test(c.title || ''));
for (const c of testCards) {
await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
purged.cards++;
}
} catch {}
try {
const buckets = await fetch('api/buckets').then(r => r.json());
const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || ''));
for (const b of testBuckets) {
await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
purged.buckets++;
}
} catch {}
// Also clear any leftover editor drafts so the restore prompt doesn't fire
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
}
} catch {}
return purged;
}
async function restoreBackup() {
if (!backupBlob) return;
const form = new FormData();
@@ -396,10 +494,18 @@ 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>';
// 1. Purge leftover test artifacts from any previous failed run
output.innerHTML = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
summary.textContent = '';
await sleep(50);
const purged = await purgeTestLeftovers();
if (purged.cards || purged.buckets) {
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
}
// 2. Create backup (clean baseline)
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
await sleep(50);
try {
await createBackup();
} catch (e) {
@@ -411,6 +517,15 @@ async function run() {
stopBackgroundSync();
window._testRunning = true;
// 2b. Snapshot + reset view state. Tests assume a normal board with
// no filters / super-view off / manual sort — if the user left the
// app in super-view or with a bucket filter active, assertions on
// .card[data-id], card count, etc. read wrong initial values.
const viewSnap = snapshotViewState();
await closeAll();
resetViewState();
await sleep(50);
// 3. Hide panel so tests run against visible UI
panel.style.display = 'none';
await sleep(100);
@@ -435,6 +550,11 @@ async function run() {
await window._reloadAll?.();
await sleep(200);
} catch {}
// Re-reset the view in case a failing test left it in super-view,
// a bucket filter, or a sorted mode — otherwise the retry runs
// against the same wrong baseline that flaked it the first time.
resetViewState();
await sleep(50);
const retryResults = await runAll(failedKeys);
const retryMap = new Map();
for (const sr of retryResults) {
@@ -467,6 +587,11 @@ async function run() {
await sleep(200);
} catch {}
// 6b. Put the view back the way the user left it (super-view, sort,
// filters, …) so launching the test runner doesn't silently wipe
// their working context.
restoreViewState(viewSnap);
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
window._testRunning = false;
setTimeout(() => startBackgroundSync(), 60000);
+28 -8
View File
@@ -180,7 +180,7 @@ describe('Super-buckets: Bar rendering', () => {
expect(superTile.textContent).toContain('__sb_active_child__');
});
it('clicking the super-tile opens a popover with parent + child entries', async () => {
it('clicking the caret opens a popover with parent + child entries', async () => {
const parent = await makeBucket('__sb_pop_parent__');
const child1 = await makeBucket('__sb_pop_child1__');
const child2 = await makeBucket('__sb_pop_child2__');
@@ -191,15 +191,15 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
const caret = superTile.querySelector('[data-super-caret]');
expect(caret).toExist();
click(caret);
await sleep(100);
const popover = document.getElementById('bucket-super-popover');
expect(popover).toExist();
// Parent + 2 children = 3 entries
const entries = popover.querySelectorAll('[data-bucket]');
expect(entries.length).toBe(3);
// All three names appear
const text = popover.textContent;
expect(text).toContain('__sb_pop_parent__');
expect(text).toContain('__sb_pop_child1__');
@@ -215,7 +215,8 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
const caret = superTile.querySelector('[data-super-caret]');
click(caret);
await sleep(100);
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
@@ -226,7 +227,25 @@ describe('Super-buckets: Bar rendering', () => {
expect(String(state.activeBucket)).toBe(String(child.id));
});
it('clicking the super-tile twice toggles the popover closed', async () => {
it('clicking the super-tile activates the shown bucket', async () => {
const parent = await makeBucket('__sb_direct_parent__');
const child = await makeBucket('__sb_direct_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
state.activeBucket = 'all';
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(150);
const shownId = superTile.dataset.superShown;
expect(String(state.activeBucket)).toBe(String(shownId));
expect(document.getElementById('bucket-super-popover')).toBe(null);
});
it('clicking the caret twice toggles the popover closed', async () => {
const parent = await makeBucket('__sb_toggle_parent__');
const child = await makeBucket('__sb_toggle_child__');
await patchBucket(child.id, { parent_id: parent.id });
@@ -235,10 +254,11 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
const caret = superTile.querySelector('[data-super-caret]');
click(caret);
await sleep(100);
expect(document.getElementById('bucket-super-popover')).toExist();
click(superTile);
click(caret);
await sleep(100);
expect(document.getElementById('bucket-super-popover')).toBe(null);
});
+196
View File
@@ -0,0 +1,196 @@
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
import { state } from '../../js/modules/state.js';
function termToggle() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'T', shiftKey: true, bubbles: true }));
}
function termExec(cmd) {
const input = document.getElementById('hterm-input');
if (!input) return;
input.value = cmd;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
}
function termOutput() {
return document.getElementById('hterm-output')?.textContent || '';
}
async function makeCard(title, body = '') {
return fetch('api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ title, body, color: '#38bdf8' }),
}).then(r => r.json());
}
describe('Terminal: Toggle', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
await closeAll();
});
it('panel exists but is hidden', () => {
const panel = document.getElementById('hterm');
expect(panel).toExist();
expect(panel.classList.contains('hidden')).toBe(true);
});
it('Shift+T opens the terminal', async () => {
termToggle();
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(false);
});
it('Shift+T again closes it', async () => {
termToggle();
await sleep(50);
termToggle();
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
});
it('ESC closes the terminal', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
});
});
describe('Terminal: Commands', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
document.getElementById('hterm-output').innerHTML = '';
await closeAll();
});
it('help lists commands', async () => {
termToggle();
await sleep(50);
termExec('help');
await sleep(50);
const out = termOutput();
expect(out).toContain('help');
expect(out).toContain('new');
expect(out).toContain('search');
});
it('count shows numbers', async () => {
termToggle();
await sleep(50);
termExec('count');
await sleep(50);
expect(termOutput()).toContain('cards');
expect(termOutput()).toContain('buckets');
});
it('version shows version string', async () => {
termToggle();
await sleep(50);
termExec('version');
await sleep(50);
expect(termOutput()).toContain('v');
});
it('unknown command shows error', async () => {
termToggle();
await sleep(50);
termExec('xyznonexistent');
await sleep(50);
expect(termOutput()).toContain('unknown command');
});
it('tags lists existing tags', async () => {
await makeCard('__term_tag_test__', '#alpha #beta');
await window._reloadAll?.();
await sleep(200);
termToggle();
await sleep(50);
termExec('tags');
await sleep(50);
expect(termOutput()).toContain('#alpha');
expect(termOutput()).toContain('#beta');
});
it('find matches cards by title', async () => {
await makeCard('__term_find_me__', 'findable body');
await window._reloadAll?.();
await sleep(200);
termToggle();
await sleep(50);
termExec('find __term_find_me__');
await sleep(50);
expect(termOutput()).toContain('__term_find_me__');
});
it('go switches to bucket', async () => {
termToggle();
await sleep(50);
termExec('go all');
await sleep(100);
expect(String(state.activeBucket)).toBe('all');
});
it('new creates a card', async () => {
termToggle();
await sleep(50);
termExec('new __term_new_card__');
// termExec fires off an async API call + full reload; poll rather
// than sleeping a fixed amount so slow machines don't flake.
const start = Date.now();
while (!termOutput().includes('created') && Date.now() - start < 3000) {
await sleep(50);
}
expect(termOutput()).toContain('created');
const cards = await fetch('api/cards').then(r => r.json());
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
});
it('clear empties output', async () => {
termToggle();
await sleep(50);
termExec('help');
await sleep(50);
expect(termOutput().length).toBeGreaterThan(10);
termExec('clear');
await sleep(50);
expect(document.getElementById('hterm-output').children.length).toBe(0);
});
});
describe('Terminal: Tab Completion', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
document.getElementById('hterm-output').innerHTML = '';
await closeAll();
});
it('Tab completes unique command', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.value = 'vers';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
await sleep(50);
expect(input.value).toBe('version ');
});
it('Tab shows multiple matches', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.value = 'c';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
await sleep(50);
const out = termOutput();
expect(out).toContain('clear');
expect(out).toContain('count');
expect(out).toContain('calendar');
expect(out).toContain('color');
});
});
+4
View File
@@ -39,6 +39,7 @@ const FILES = [
'./editor-toolbar.test.js',
'./line-numbers.test.js',
'./canvas.test.js',
'./tim.test.js',
'./snippets.test.js',
'./image-fullscreen.test.js',
'./markdown.test.js',
@@ -50,7 +51,10 @@ const FILES = [
'./relay.test.js',
'./notifications.test.js',
'./quick-capture.test.js',
'./asset-picker.test.js',
'./terminal.test.js',
'./theme.test.js',
'./chat.test.js',
];
// Await all imports so the runner can start in a deterministic state.
+330
View File
@@ -0,0 +1,330 @@
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js?v=4';
// ── Helpers ──
function openTim() {
window._timToggle?.();
}
function closeTim() {
const overlay = $('#tim-overlay');
if (overlay && !overlay.classList.contains('hidden')) window._timToggle?.();
}
function isOpen() {
return !$('#tim-overlay').classList.contains('hidden');
}
function setInput(id, value) {
const el = $('#' + id);
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
function activateTab(name) {
const tab = document.querySelector(`.tim-tab[data-tim-tab="${name}"]`);
tab.click();
}
// ── Registration ──
describe('Tim Plugin: Registration', () => {
it('plugin is registered with hyprcrd', () => {
const meta = window.hyprcrd.plugins.find(p => p.name === 'tim');
expect(meta).toExist();
});
it('tim editor command is registered', () => {
expect(window.hyprcrd.editor.getCommand('tim')).toExist();
});
it('toolbar clock button is injected', () => {
const btn = document.querySelector('#md-toolbar [data-md="tim"]');
expect(btn).toExist();
});
it('window._timToggle is exposed', () => {
expect(typeof window._timToggle).toBe('function');
});
});
// ── Structure ──
describe('Tim Plugin: Structure', () => {
it('overlay and panel exist in DOM', () => {
expect($('#tim-overlay')).toExist();
expect(document.querySelector('.tim-panel')).toExist();
});
it('overlay is hidden initially', () => {
expect($('#tim-overlay').classList.contains('hidden')).toBe(true);
});
it('all three tabs exist', () => {
expect(document.querySelector('.tim-tab[data-tim-tab="duration"]')).toExist();
expect(document.querySelector('.tim-tab[data-tim-tab="add"]')).toExist();
expect(document.querySelector('.tim-tab[data-tim-tab="workday"]')).toExist();
});
it('all inputs + result fields exist', () => {
const ids = [
'tim-dur-start', 'tim-dur-end', 'tim-dur-result',
'tim-add-time', 'tim-add-dur', 'tim-add-result',
'tim-wd-start', 'tim-wd-break-from', 'tim-wd-break-back',
'tim-wd-target', 'tim-wd-result',
];
for (const id of ids) expect($('#' + id)).toExist();
});
it('target input has no default value (placeholder only)', () => {
expect($('#tim-wd-target').value).toBe('');
expect($('#tim-wd-target').placeholder).toBe('0800');
});
});
// ── Open / Close ──
describe('Tim Plugin: Open & Close', () => {
afterEach(async () => { closeTim(); await closeAll(); });
it('toolbar button opens tim', async () => {
const btn = document.querySelector('#md-toolbar [data-md="tim"]');
btn.click();
await sleep(30);
expect(isOpen()).toBe(true);
});
it('_timToggle toggles visibility', async () => {
openTim(); await sleep(30);
expect(isOpen()).toBe(true);
openTim(); await sleep(30);
expect(isOpen()).toBe(false);
});
it('close button hides overlay', async () => {
openTim(); await sleep(30);
click('#tim-close');
await sleep(30);
expect(isOpen()).toBe(false);
});
it('Escape closes overlay', async () => {
openTim(); await sleep(30);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(30);
expect(isOpen()).toBe(false);
});
it('clicking backdrop closes overlay', async () => {
openTim(); await sleep(30);
const overlay = $('#tim-overlay');
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
// Backdrop-click requires e.target === overlay, which happens naturally
// for a click on the overlay itself.
await sleep(30);
expect(isOpen()).toBe(false);
});
});
// ── Tab switching ──
describe('Tim Plugin: Tabs', () => {
afterEach(async () => { closeTim(); await closeAll(); });
it('duration tab is active on load', () => {
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
expect(tab.classList.contains('active')).toBe(true);
});
it('clicking Add tab activates it and shows its panel', async () => {
openTim(); await sleep(30);
activateTab('add');
await sleep(20);
expect(document.querySelector('.tim-tab[data-tim-tab="add"]').classList.contains('active')).toBe(true);
expect(document.querySelector('[data-tim-panel="add"]').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-tim-panel="duration"]').classList.contains('hidden')).toBe(true);
});
it('switching to Workday reveals all four workday inputs', async () => {
openTim(); await sleep(30);
activateTab('workday');
await sleep(20);
const panel = document.querySelector('[data-tim-panel="workday"]');
expect(panel.classList.contains('hidden')).toBe(false);
expect(panel.querySelectorAll('input').length).toBe(4);
});
});
// ── Duration calculation ──
describe('Tim Plugin: Duration Calc', () => {
afterEach(async () => { closeTim(); await closeAll(); });
it('0830 → 1715 is 8h 45m', async () => {
openTim(); await sleep(30);
setInput('tim-dur-start', '0830');
setInput('tim-dur-end', '1715');
expect($('#tim-dur-result').textContent).toBe('8h 45m');
});
it('wraps past midnight (2200 → 0200 = 4h 00m)', async () => {
openTim(); await sleep(30);
setInput('tim-dur-start', '2200');
setInput('tim-dur-end', '0200');
expect($('#tim-dur-result').textContent).toBe('4h 00m');
});
it('colon format (08:30) is accepted', async () => {
openTim(); await sleep(30);
setInput('tim-dur-start', '08:30');
setInput('tim-dur-end', '09:00');
expect($('#tim-dur-result').textContent).toBe('0h 30m');
});
it('empty input clears the result', async () => {
openTim(); await sleep(30);
setInput('tim-dur-start', '0800');
setInput('tim-dur-end', '0900');
expect($('#tim-dur-result').textContent).toBe('1h 00m');
setInput('tim-dur-end', '');
expect($('#tim-dur-result').textContent).toBe('');
});
it('invalid input (minutes >= 60) clears the result', async () => {
openTim(); await sleep(30);
setInput('tim-dur-start', '0880');
setInput('tim-dur-end', '1000');
expect($('#tim-dur-result').textContent).toBe('');
});
});
// ── Add calculation ──
describe('Tim Plugin: Add Calc', () => {
afterEach(async () => { closeTim(); await closeAll(); });
it('0830 + 0130 = 1000', async () => {
openTim(); await sleep(30);
activateTab('add'); await sleep(10);
setInput('tim-add-time', '0830');
setInput('tim-add-dur', '0130');
expect($('#tim-add-result').textContent).toBe('1000');
});
it('wraps around 24h (2330 + 0100 = 0030)', async () => {
openTim(); await sleep(30);
activateTab('add'); await sleep(10);
setInput('tim-add-time', '2330');
setInput('tim-add-dur', '0100');
expect($('#tim-add-result').textContent).toBe('0030');
});
});
// ── Workday calculation ──
describe('Tim Plugin: Workday Calc', () => {
afterEach(async () => { closeTim(); await closeAll(); });
it('start 0800, break 1200→1245, target default 8h → 1645', async () => {
openTim(); await sleep(30);
activateTab('workday'); await sleep(10);
setInput('tim-wd-start', '0800');
setInput('tim-wd-break-from', '1200');
setInput('tim-wd-break-back', '1245');
expect($('#tim-wd-result').textContent).toContain('End at 1645');
});
it('custom target of 0400 → ends earlier', async () => {
openTim(); await sleep(30);
activateTab('workday'); await sleep(10);
setInput('tim-wd-start', '0800');
setInput('tim-wd-break-from', '1000');
setInput('tim-wd-break-back', '1015');
setInput('tim-wd-target', '0400');
// 2h before break + 2h after = 4h; 10:15 + 2h = 1215
expect($('#tim-wd-result').textContent).toContain('End at 1215');
});
it('break before start shows an error message', async () => {
openTim(); await sleep(30);
activateTab('workday'); await sleep(10);
setInput('tim-wd-start', '1000');
setInput('tim-wd-break-from', '0900');
setInput('tim-wd-break-back', '0930');
expect($('#tim-wd-result').textContent).toContain('Break must be after start');
expect($('#tim-wd-result').classList.contains('err')).toBe(true);
});
it('target already reached before break → reports the finish time', async () => {
openTim(); await sleep(30);
activateTab('workday'); await sleep(10);
setInput('tim-wd-start', '0800');
setInput('tim-wd-break-from', '1800'); // more than 8h before break
setInput('tim-wd-break-back', '1815');
setInput('tim-wd-target', '0800');
expect($('#tim-wd-result').textContent).toContain('Target already reached');
});
});
// ── Focus selection + Tab cycling ──
describe('Tim Plugin: Focus & Tab Cycling', () => {
// Previous describes (Add Calc / Workday Calc) leave their tab active,
// which leaves the duration panel `.hidden` and breaks our Tab-cycle
// handler's closest('.tim-tab-body:not(.hidden)') lookup. Reset first.
beforeEach(() => {
document.querySelectorAll('#tim-overlay input').forEach(i => { i.value = ''; });
activateTab('duration');
});
afterEach(async () => { closeTim(); await closeAll(); });
// Spies HTMLInputElement.prototype.focus so we can detect which input
// the handler *tried* to focus without depending on document.activeElement
// (which is unreliable in some synthetic-event runs).
function withFocusSpy(fn) {
const orig = HTMLInputElement.prototype.focus;
let last = null;
HTMLInputElement.prototype.focus = function() { last = this; return orig.apply(this, arguments); };
try { return fn(() => last); } finally { HTMLInputElement.prototype.focus = orig; }
}
it('focusing an input with value selects the text', async () => {
openTim(); await sleep(30);
const el = $('#tim-dur-start');
el.value = '0830';
el.focus();
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(el.selectionStart).toBe(0);
expect(el.selectionEnd).toBe(el.value.length);
});
it('Tab from last input wraps to the first within the active panel', async () => {
openTim(); await sleep(30);
const first = $('#tim-dur-start');
const last = $('#tim-dur-end');
withFocusSpy(getLast => {
last.focus();
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
expect(getLast()).toBe(first);
});
});
it('Shift+Tab from first wraps to the last within the active panel', async () => {
openTim(); await sleep(30);
const first = $('#tim-dur-start');
const last = $('#tim-dur-end');
withFocusSpy(getLast => {
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
expect(getLast()).toBe(last);
});
});
it('Tab cycling stays within the active tab (workday has 4 inputs)', async () => {
openTim(); await sleep(30);
activateTab('workday'); await sleep(10);
const inputs = [...document.querySelectorAll('[data-tim-panel="workday"] input')];
expect(inputs.length).toBe(4);
withFocusSpy(getLast => {
inputs[3].focus();
inputs[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
expect(getLast()).toBe(inputs[0]);
});
});
});
+20 -5
View File
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
bodyEl.value = 'after edit';
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
click('#modal-save-close');
await sleep(400);
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
let card;
for (let i = 0; i < 40; i++) {
await sleep(100);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
if ((card?.body || '').includes('after edit')) break;
}
expect(card.body).toContain('after edit');
ctrlZ();
await sleep(400);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
for (let i = 0; i < 40; i++) {
await sleep(100);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
if ((card?.body || '').includes('before edit')) break;
}
expect(card.body).toContain('before edit');
});
@@ -350,7 +357,15 @@ describe('Undo: No-Op When Empty', () => {
ctrlZ();
await sleep(200);
}
// Now Ctrl+Z should be harmless
// Wait for state to settle — previous undo operations can still be
// resurrecting cards asynchronously after canUndo() has flipped to
// false. Poll until two consecutive fetches agree on card count.
let prev = -1, stable = 0;
for (let i = 0; i < 30 && stable < 2; i++) {
const cards = await fetch('api/cards').then(r => r.json());
if (cards.length === prev) stable++; else { stable = 0; prev = cards.length; }
if (stable < 2) await sleep(100);
}
const cardsBefore = await fetch('api/cards').then(r => r.json());
ctrlZ();
await sleep(200);
+38
View File
@@ -0,0 +1,38 @@
# tim plugin
Tiny time calculator. Three tabs: duration between two times, add a duration to a time, and workday-end with a break.
## Open
- **`I`** on the board
- **Clock icon** in the card-edit toolbar
- Terminal: `tim`
Close with **ESC** or the × button.
## Tabs
### Duration
Two HHMM inputs → how many hours and minutes between them. Wraps past midnight.
### Add
Time + duration → resulting HHMM. Wraps around 24h.
### Workday
Start, break from, break back, target (defaults to 08:00). Computes when the workday ends to hit the target, or tells you it's already reached.
## Input format
All inputs take HHMM-style strings:
- `0830`, `830`, `08:30`, `8:30` → 08:30
- Missing digits are left-padded with zeros
- Minute component must be < 60
Invalid input clears the result silently — no red flashes while typing.
Calculation is live: the result updates as you type in each field.
## Disabling
Add `"tim"` to the `disabled` array in `data/plugins.json`.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "tim",
"version": "1.0.0",
"description": "Time calculator — duration between two times, add duration to time, end-of-day for target work hours.",
"frontend": {
"js": ["tim.js"],
"css": ["tim.css"],
"html": ["tim.html"]
}
}
+85
View File
@@ -0,0 +1,85 @@
#tim-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center;
padding: 40px;
}
#tim-overlay.hidden { display: none; }
.tim-panel {
background: var(--t-surface); border: 1px solid var(--t-border);
border-radius: var(--ui-radius-modal, 4px);
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
width: 100%; max-width: 600px;
display: flex; flex-direction: column;
overflow: hidden;
}
.tim-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--t-border);
}
.tim-header h2 { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 12px; color: var(--t-accent); margin: 0; }
.tim-spacer { flex: 1; }
.tim-btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px;
padding: 5px 12px; background: transparent;
border: 1px solid var(--t-border); color: var(--t-muted); cursor: pointer;
border-radius: var(--ui-radius-button);
transition: all 0.15s;
}
.tim-btn-secondary:hover { border-color: var(--t-danger); color: var(--t-danger); }
.tim-tabs { display: flex; border-bottom: 1px solid var(--t-border); }
.tim-tab {
flex: 1; padding: 10px 16px; background: transparent; border: none;
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px;
color: var(--t-muted); cursor: pointer; transition: all 0.15s;
border-bottom: 2px solid transparent;
}
.tim-tab:hover { color: var(--t-bright); }
.tim-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
.tim-tab-body { padding: 20px; }
.tim-tab-body.hidden { display: none; }
.tim-hint {
font-size: 11px; color: var(--t-dim);
margin-bottom: 16px; line-height: 1.5;
}
.tim-fields {
display: flex; justify-content: center; flex-wrap: wrap;
gap: 20px 28px; margin-bottom: 16px;
}
.tim-field {
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.tim-field label {
font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 9px;
color: var(--t-dim); text-transform: uppercase; letter-spacing: 0.5px;
}
.tim-field input {
background: var(--t-bg); border: 1px solid var(--t-border);
color: var(--t-bright); padding: 6px 10px;
border-radius: var(--ui-radius-input);
font-family: var(--t-font-content, monospace); font-size: 13px;
width: 110px; text-align: center; letter-spacing: 2px;
outline: none; transition: border-color 0.15s;
}
.tim-field input:focus { border-color: var(--t-accent); }
.tim-result {
margin-top: 16px; padding: 14px 16px;
border: 1px solid var(--t-border); border-radius: 4px;
font-family: var(--t-font-content, monospace); font-size: 16px;
color: var(--t-bright); text-align: center;
min-height: 48px; display: flex; align-items: center; justify-content: center;
background: var(--t-bg);
}
.tim-result.ok { border-color: var(--t-accent); color: var(--t-accent); }
.tim-result.err { border-color: var(--t-danger); color: var(--t-danger); font-size: 11px; }
.tim-result:empty::before {
content: '—'; color: var(--t-dim); opacity: 0.3;
}
+69
View File
@@ -0,0 +1,69 @@
<div id="tim-overlay" class="hidden">
<div class="tim-panel">
<div class="tim-header">
<i class="ph ph-clock text-t-accent"></i>
<h2>tim — time calculator</h2>
<span class="tim-spacer"></span>
<button id="tim-close" class="tim-btn-secondary"><i class="ph ph-x"></i> close</button>
</div>
<div class="tim-tabs">
<button class="tim-tab active" data-tim-tab="duration">Duration</button>
<button class="tim-tab" data-tim-tab="add">Add</button>
<button class="tim-tab" data-tim-tab="workday">Workday</button>
</div>
<div class="tim-tab-body" data-tim-panel="duration">
<p class="tim-hint">Duration between two times. Format HHMM, e.g. 0830-1715.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-dur-start">Start</label>
<input type="text" id="tim-dur-start" placeholder="0830" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-dur-end">End</label>
<input type="text" id="tim-dur-end" placeholder="1715" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-dur-result"></div>
</div>
<div class="tim-tab-body hidden" data-tim-panel="add">
<p class="tim-hint">Add a duration to a given time.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-add-time">Time</label>
<input type="text" id="tim-add-time" placeholder="0830" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-add-dur">Duration</label>
<input type="text" id="tim-add-dur" placeholder="0130" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-add-result"></div>
</div>
<div class="tim-tab-body hidden" data-tim-panel="workday">
<p class="tim-hint">End time to reach target work duration, given start, break start, and break end.</p>
<div class="tim-fields">
<div class="tim-field">
<label for="tim-wd-start">Start</label>
<input type="text" id="tim-wd-start" placeholder="0820" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-break-from">Break from</label>
<input type="text" id="tim-wd-break-from" placeholder="1200" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-break-back">Back from break</label>
<input type="text" id="tim-wd-break-back" placeholder="1245" maxlength="4">
</div>
<div class="tim-field">
<label for="tim-wd-target">Target</label>
<input type="text" id="tim-wd-target" placeholder="0800" maxlength="4">
</div>
</div>
<div class="tim-result" id="tim-wd-result"></div>
</div>
</div>
</div>
+178
View File
@@ -0,0 +1,178 @@
// ── tim — time calculator plugin ──
const $ = id => document.getElementById(id);
const h = window.hyprcrd;
const overlay = $('tim-overlay');
function show() { overlay?.classList.remove('hidden'); $('tim-dur-start')?.focus(); }
function hide() { overlay?.classList.add('hidden'); }
function toggle() { overlay?.classList.contains('hidden') ? show() : hide(); }
function pad4(n) { return String(n).padStart(4, '0'); }
// Parse HHMM string → total minutes since midnight, or null on invalid
function parseHHMM(s) {
if (!s) return null;
s = String(s).trim().replace(':', '').padStart(4, '0');
if (!/^\d{3,4}$/.test(s)) return null;
const h = parseInt(s.slice(0, -2));
const m = parseInt(s.slice(-2));
if (m >= 60) return null;
return h * 60 + m;
}
function minutesToHHMM(mins) {
mins = ((mins % (24 * 60)) + 24 * 60) % (24 * 60);
return pad4(Math.floor(mins / 60) * 100 + (mins % 60));
}
function formatDuration(mins) {
if (mins < 0) mins += 24 * 60;
const h = Math.floor(mins / 60), m = mins % 60;
return `${h}h ${String(m).padStart(2, '0')}m`;
}
// ── Tab switching ──
document.querySelectorAll('.tim-tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tim-tab').forEach(x => x.classList.toggle('active', x === t));
document.querySelectorAll('.tim-tab-body').forEach(p => {
p.classList.toggle('hidden', p.dataset.timPanel !== t.dataset.timTab);
});
const firstInput = document.querySelector(`[data-tim-panel="${t.dataset.timTab}"] input`);
firstInput?.focus();
});
});
// ── Duration tab ──
function calcDuration() {
const res = $('tim-dur-result');
const s = parseHHMM($('tim-dur-start').value);
const e = parseHHMM($('tim-dur-end').value);
if (s == null || e == null) {
res.className = 'tim-result';
res.textContent = '';
return;
}
const diff = e - s;
res.className = 'tim-result ok';
res.textContent = formatDuration(diff);
}
// ── Add tab ──
function calcAdd() {
const res = $('tim-add-result');
const t = parseHHMM($('tim-add-time').value);
const d = parseHHMM($('tim-add-dur').value);
if (t == null || d == null) {
res.className = 'tim-result';
res.textContent = '';
return;
}
res.className = 'tim-result ok';
res.textContent = minutesToHHMM(t + d);
}
// ── Workday tab ──
function calcWorkday() {
const res = $('tim-wd-result');
const s = parseHHMM($('tim-wd-start').value);
const bf = parseHHMM($('tim-wd-break-from').value);
const bb = parseHHMM($('tim-wd-break-back').value);
const target = parseHHMM($('tim-wd-target').value) ?? 480;
if (s == null || bf == null || bb == null) {
res.className = 'tim-result';
res.textContent = '';
return;
}
const workedBeforeBreak = bf - s;
if (workedBeforeBreak < 0) {
res.className = 'tim-result err';
res.textContent = 'Break must be after start';
return;
}
const remaining = target - workedBeforeBreak;
if (remaining < 0) {
res.className = 'tim-result ok';
res.textContent = 'Target already reached at ' + minutesToHHMM(s + target);
return;
}
const endOfDay = (bb + remaining) % (24 * 60);
res.className = 'tim-result ok';
res.textContent = `End at ${minutesToHHMM(endOfDay)} (${formatDuration(remaining)} left after break)`;
}
// Single delegated input handler on the overlay — routes to the right
// calc by input id. Robust against DOM order / re-renders.
overlay?.addEventListener('input', e => {
const id = e.target?.id || '';
if (id.startsWith('tim-dur-')) calcDuration();
else if (id.startsWith('tim-add-')) calcAdd();
else if (id.startsWith('tim-wd-')) calcWorkday();
});
// Focus → select existing text so typing replaces it.
overlay?.addEventListener('focusin', e => {
if (e.target.tagName === 'INPUT' && e.target.value) e.target.select();
});
// Tab cycles within the currently active tab panel's inputs.
overlay?.addEventListener('keydown', e => {
if (e.key !== 'Tab') return;
const panel = e.target.closest('.tim-tab-body:not(.hidden)');
if (!panel) return;
const inputs = [...panel.querySelectorAll('input')];
if (!inputs.length) return;
const idx = inputs.indexOf(e.target);
if (idx === -1) return;
e.preventDefault();
const next = e.shiftKey
? (idx - 1 + inputs.length) % inputs.length
: (idx + 1) % inputs.length;
inputs[next].focus();
});
// ── Global bindings ──
$('tim-close')?.addEventListener('click', hide);
overlay?.addEventListener('click', e => { if (e.target === overlay) hide(); });
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && !overlay?.classList.contains('hidden')) {
e.preventDefault(); e.stopPropagation();
hide();
}
}, { capture: true });
// Expose toggle for terminal / shortcuts
window._timToggle = toggle;
// Register toolbar command — the md-toolbar delegated click handler in
// editor.js routes clicks on [data-md="tim"] through this.
h?.editor?.registerCommand('tim', () => toggle());
function injectToolbarButton() {
const toolbar = document.getElementById('md-toolbar');
if (!toolbar) return;
if (toolbar.querySelector('[data-md="tim"]')) return;
const btn = document.createElement('button');
btn.setAttribute('data-md', 'tim');
btn.setAttribute('data-help', 'tim — time calculator\nDuration, add, workday end.\nShortcut: I');
btn.className = 'toolbar-btn';
btn.innerHTML = '<i class="ph ph-clock text-sm"></i>';
const spacer = toolbar.querySelector('.flex-1');
if (spacer) toolbar.insertBefore(btn, spacer);
else toolbar.appendChild(btn);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectToolbarButton);
} else {
injectToolbarButton();
}
h?.registerPlugin({ name: 'tim', version: '1.0.0' });
@@ -0,0 +1,21 @@
<svg
width="176.00125mm"
height="176.00124mm"
viewBox="0 0 176.00125 176.00124"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-16.173035,-17.825715)"><path
id="rect233"
style="display:inline;fill:var(--watch-svg-markers-fill, var(--watch-num-color, #fff));fill-opacity:1;stroke:none;stroke-width:0.19504;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
d="m 411.61352,-261.01455 c 1.3819,0 2.49444,1.11255 2.49444,2.49445 v 8.93128 c 0,1.38191 -1.11254,2.49445 -2.49444,2.49445 -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 v -8.93128 c 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m 53.93185,14.43191 c 0.40192,0.0109 0.80726,0.11988 1.18125,0.33581 1.19676,0.69095 1.60412,2.21084 0.91316,3.40761 l -4.46598,7.73467 c -0.69096,1.19677 -2.21015,1.60342 -3.40692,0.91246 -1.19677,-0.69095 -1.60411,-2.21015 -0.91316,-3.40691 l 4.46529,-7.73467 c 0.47503,-0.82278 1.34214,-1.27285 2.22636,-1.24897 z m -107.86371,0 c 0.88422,-0.0239 1.75063,0.42619 2.22566,1.24897 l 4.46599,7.73467 c 0.69095,1.19676 0.28361,2.71596 -0.91316,3.40691 -1.19676,0.69095 -2.71666,0.2843 -3.40761,-0.91246 l -4.46529,-7.73467 c -0.69096,-1.19677 -0.2843,-2.71666 0.91246,-3.40761 0.374,-0.21593 0.78004,-0.32495 1.18195,-0.33581 z m 147.16469,39.43293 c 0.88422,-0.0239 1.75063,0.42549 2.22566,1.24827 0.69095,1.19676 0.2843,2.71666 -0.91246,3.40761 l -7.73467,4.46599 c -1.19677,0.69095 -2.71667,0.28359 -3.40762,-0.91317 -0.69095,-1.19677 -0.28359,-2.71666 0.91317,-3.40761 l 7.73467,-4.46529 c 0.37399,-0.21592 0.77934,-0.32495 1.18125,-0.3358 z m -186.46637,0 c 0.40192,0.0109 0.80797,0.11988 1.18195,0.3358 l 7.73467,4.46529 c 1.19676,0.69095 1.60342,2.21084 0.91247,3.40761 -0.69095,1.19676 -2.21015,1.60412 -3.40691,0.91317 l -7.73467,-4.46599 c -1.19677,-0.69096 -1.60412,-2.21085 -0.91317,-3.40761 0.47504,-0.82279 1.34145,-1.27216 2.22566,-1.24827 z m 200.9653,53.86762 c 1.3819,0 2.49444,1.11255 2.49444,2.49445 0,1.38191 -1.11254,2.49445 -2.49444,2.49445 H 510.414 c -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m -206.53295,0 c 1.38191,0 2.49445,1.11255 2.49445,2.49445 0,1.38191 -1.11254,2.49445 -2.49445,2.49445 h -8.93127 c -1.38191,0 -2.49445,-1.11254 -2.49445,-2.49445 0,-1.3819 1.11254,-2.49445 2.49445,-2.49445 z m 184.4306,49.39885 c 0.40192,0.0109 0.80796,0.11988 1.18195,0.3358 l 7.73467,4.465294 c 1.19676,0.690953 1.60341,2.210851 0.91246,3.407611 -0.69095,1.19677 -2.21015,1.604118 -3.40691,0.913165 l -7.73467,-4.465989 c -1.19677,-0.690952 -1.60412,-2.210151 -0.91317,-3.406911 0.47503,-0.82278 1.34145,-1.27286 2.22567,-1.24897 z m -171.25952,0 c 0.88421,-0.0239 1.75063,0.42619 2.22566,1.24897 0.69095,1.19676 0.2843,2.715956 -0.91247,3.406911 l -7.73467,4.465989 c -1.19676,0.69095 -2.71665,0.283605 -3.40761,-0.913165 -0.69095,-1.19676 -0.2836,-2.716656 0.91317,-3.407611 l 7.73467,-4.465294 c 0.37399,-0.21592 0.77933,-0.32495 1.18125,-0.3358 z m 134.96472,36.163545 c 0.88422,-0.02388 1.75064,0.426193 2.22567,1.248969 l 4.46598,7.73467 c 0.69096,1.196766 0.2836,2.716662 -0.91316,3.407612 -1.19677,0.690955 -2.71666,0.2836 -3.40761,-0.913165 l -4.46529,-7.73467 c -0.69095,-1.196766 -0.2836,-2.716661 0.91316,-3.407611 0.374,-0.215925 0.77933,-0.324948 1.18125,-0.335805 z m -98.66923,0 c 0.40192,0.01085 0.80726,0.11988 1.18125,0.335805 1.19676,0.69095 1.60411,2.21085 0.91316,3.407611 l -4.46599,7.73467 c -0.69095,1.196769 -2.21085,1.604115 -3.40761,0.913165 -1.19677,-0.690955 -1.60342,-2.210842 -0.91246,-3.407612 l 4.46529,-7.73467 c 0.47503,-0.822776 1.34215,-1.272853 2.22636,-1.248969 z m 49.33462,13.238092 c 1.3819,0 2.49444,1.112541 2.49444,2.494447 v 8.931279 c 0,1.381905 -1.11254,2.494447 -2.49444,2.494447 -1.38191,0 -2.49445,-1.112542 -2.49445,-2.494447 v -8.931279 c 0,-1.381906 1.11254,-2.494447 2.49445,-2.494447 z"
transform="matrix(-0.74020373,0,0,0.74020373,408.85134,217.42417)" /><circle
style="fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
id="circle256"
cx="104.17366"
cy="105.82634"
r="88.000626" /></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,20 @@
<svg
width="176.00125mm"
height="176.00124mm"
viewBox="0 0 176.00125 176.00124"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-16.173035,-17.825715)"><path
id="rect256"
style="display:inline;fill:var(--watch-svg-hour-fill, var(--watch-hand-color, #fff));fill-opacity:1;stroke-width:0.809242;stroke-linejoin:round;paint-order:fill markers stroke"
d="m 104.17365,63.017641 c 4.52102,0 8.16069,3.639666 8.16069,8.160687 v 34.700732 c 0,4.52102 -3.63967,8.16069 -8.16069,8.16069 -4.521021,0 -8.160688,-3.63967 -8.160688,-8.16069 V 71.178328 c 0,-4.521021 3.639667,-8.160687 8.160688,-8.160687 z" /><circle
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
id="circle256"
cx="104.17366"
cy="105.82634"
r="88.000626" /></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,20 @@
<svg
width="176.00125mm"
height="176.00124mm"
viewBox="0 0 176.00125 176.00124"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-16.173035,-17.825715)"><path
id="rect231"
style="display:inline;fill:var(--watch-svg-minute-fill, var(--watch-hand-color, #fff));stroke-width:0.809242;stroke-linejoin:round;paint-order:fill markers stroke"
d="m 104.17365,40.792641 c 4.52102,0 8.16069,3.639666 8.16069,8.160687 v 56.925732 c 0,4.52102 -3.63967,8.16069 -8.16069,8.16069 -4.521021,0 -8.160688,-3.63967 -8.160688,-8.16069 V 48.953328 c 0,-4.521021 3.639667,-8.160687 8.160688,-8.160687 z" /><circle
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
id="circle256"
cx="104.17366"
cy="105.82634"
r="88.000626" /></g></svg>

After

Width:  |  Height:  |  Size: 1013 B

@@ -0,0 +1,25 @@
<svg
width="176.00125mm"
height="176.00124mm"
viewBox="0 0 176.00125 176.00124"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-16.173035,-17.825715)"><rect
style="display:inline;fill:var(--watch-svg-second-fill, var(--watch-second-color, #fff));fill-opacity:1;stroke:none;stroke-width:0.144369;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
id="rect237"
width="3.69275"
height="10.303658"
x="-106.02003"
y="24.220301"
ry="1.846375"
transform="scale(-1,1)" /><circle
style="display:none;fill:none;fill-opacity:0.38222;stroke-width:0.0278984;stroke-linejoin:round;paint-order:fill markers stroke"
id="circle256"
cx="104.17366"
cy="105.82634"
r="88.000626" /></g></svg>

After

Width:  |  Height:  |  Size: 972 B

+10
View File
@@ -0,0 +1,10 @@
{
"name": "watch",
"version": "1.0.0",
"description": "Analog clock overlay. Shows after 5 min idle or press W.",
"frontend": {
"js": ["watch.js"],
"css": ["watch.css"],
"html": ["watch.html"]
}
}
+93
View File
@@ -0,0 +1,93 @@
/* ── Watch overlay ── */
#watch-overlay {
position: fixed; inset: 0; z-index: 9000;
display: grid; place-items: center;
cursor: pointer; overflow: hidden;
opacity: 0; transform: scale(0.985);
transition: opacity 220ms ease, transform 220ms ease;
pointer-events: none;
}
#watch-overlay.visible {
opacity: 1; transform: scale(1);
pointer-events: auto;
}
#watch-overlay.hidden { display: none; }
#watch-darkening {
position: absolute; inset: 0;
background: #000; opacity: 0.8;
pointer-events: none;
backdrop-filter: blur(var(--ui-blur-overlay, 0px));
-webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
}
#watch-analog {
position: relative; z-index: 1;
width: min(78vmin, 720px);
aspect-ratio: 1;
}
#watch-dial {
position: relative; width: 100%; height: 100%;
border-radius: 50%;
}
.watch-layer {
position: absolute; inset: 0;
transform-origin: 50% 50%;
will-change: transform;
}
.watch-layer svg { display: block; width: 100%; height: 100%; }
#watch-layer-digits { z-index: 1; filter: drop-shadow(0 0 12px rgba(0,0,0,0.8)); }
#watch-layer-hour { z-index: 3; }
#watch-layer-minute { z-index: 2; }
#watch-layer-second { z-index: 4; }
#watch-second-wobble {
width: 100%; height: 100%;
transform-origin: 50% 50%;
transition: transform 92ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
/* Dark theme (default): white hands/markers */
#watch-overlay {
--watch-svg-markers-fill: #fff;
--watch-svg-hour-fill: var(--t-accent, #68c6ed);
--watch-svg-minute-fill: #fff;
--watch-svg-second-fill: var(--t-accent, #68c6ed);
}
/* Light theme: black hands/markers */
@media (prefers-color-scheme: light) {
#watch-overlay {
--watch-svg-markers-fill: #111;
--watch-svg-hour-fill: var(--t-accent, #2563eb);
--watch-svg-minute-fill: #111;
--watch-svg-second-fill: var(--t-accent, #2563eb);
}
#watch-darkening { background: #fff; opacity: 0.85; }
#watch-layer-digits { filter: drop-shadow(0 0 12px rgba(255,255,255,0.6)); }
}
/* Also detect hyprcrd's own theme mode via body class / data attribute */
body.light-theme #watch-overlay,
[data-theme-mode="light"] #watch-overlay {
--watch-svg-markers-fill: #111;
--watch-svg-hour-fill: var(--t-accent, #2563eb);
--watch-svg-minute-fill: #111;
--watch-svg-second-fill: var(--t-accent, #2563eb);
}
body.light-theme #watch-darkening,
[data-theme-mode="light"] #watch-darkening {
background: #fff; opacity: 0.85;
}
body.light-theme #watch-layer-digits,
[data-theme-mode="light"] #watch-layer-digits {
filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
}
@media (prefers-reduced-motion: reduce) {
#watch-overlay { transition: none !important; }
#watch-second-wobble { transition: none !important; }
}
+13
View File
@@ -0,0 +1,13 @@
<div id="watch-overlay" class="hidden" aria-hidden="true">
<div id="watch-darkening"></div>
<div id="watch-analog">
<div id="watch-dial">
<div id="watch-layer-digits" class="watch-layer"></div>
<div id="watch-layer-hour" class="watch-layer"></div>
<div id="watch-layer-minute" class="watch-layer"></div>
<div id="watch-layer-second" class="watch-layer">
<div id="watch-second-wobble"></div>
</div>
</div>
</div>
</div>
+169
View File
@@ -0,0 +1,169 @@
// ── Watch plugin — analog clock overlay ──
const $ = id => document.getElementById(id);
const LAYER_MAP = {
digits: 'watch-layer-digits',
hour: 'watch-layer-hour',
minute: 'watch-layer-minute',
second: 'watch-second-wobble',
};
let visible = false;
let tickTimer = null;
let idleTimer = null;
let lastActivity = Date.now();
let showedAt = 0;
let wobbleKick = null, wobbleRebound = null;
let svgCache = {};
let loaded = false;
const IDLE_MINUTES = 5;
// ── SVG loading ──
async function loadSvg(layer) {
if (svgCache[layer]) return svgCache[layer];
try {
const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`);
if (!res.ok) return '';
let svg = await res.text();
svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(/<!DOCTYPE[\s\S]*?>/gi, '').trim();
svgCache[layer] = svg;
return svg;
} catch { return ''; }
}
async function loadFace() {
if (loaded) return;
for (const [layer, elId] of Object.entries(LAYER_MAP)) {
const el = $(elId);
if (el) el.innerHTML = await loadSvg(layer);
}
loaded = true;
}
// ── Clock logic ──
function tick() {
const now = new Date();
const h12 = now.getHours() % 12;
const m = now.getMinutes();
const s = now.getSeconds();
const hourEl = $('watch-layer-hour');
const minEl = $('watch-layer-minute');
const secEl = $('watch-layer-second');
if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`;
if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`;
if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`;
triggerWobble();
}
function triggerWobble() {
const wobEl = $('watch-second-wobble');
if (!wobEl) return;
clearTimeout(wobbleKick);
clearTimeout(wobbleRebound);
wobEl.style.transform = 'rotate(0deg)';
requestAnimationFrame(() => {
wobEl.style.transform = 'rotate(0.78deg)';
wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95);
wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205);
});
}
function startTick() {
if (tickTimer) return;
tick();
tickTimer = setInterval(tick, 1000);
}
function stopTick() {
if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
clearTimeout(wobbleKick);
clearTimeout(wobbleRebound);
}
// ── Show / hide ──
async function show() {
if (visible) return;
visible = true;
const overlay = $('watch-overlay');
if (!overlay) return;
showedAt = Date.now();
await loadFace();
overlay.classList.remove('hidden');
startTick();
requestAnimationFrame(() => overlay.classList.add('visible'));
}
function hide() {
if (!visible) return;
visible = false;
const overlay = $('watch-overlay');
if (!overlay) return;
overlay.classList.remove('visible');
stopTick();
setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250);
}
function toggle() { visible ? hide() : show(); }
// ── Idle auto-show ──
function resetIdleTimer() {
clearTimeout(idleTimer);
if (!IDLE_MINUTES) return;
idleTimer = setTimeout(() => {
if (visible) return;
if (document.visibilityState !== 'visible') return;
if (document.querySelector('.overlay.open')) return;
show();
}, IDLE_MINUTES * 60000);
}
function onActivity() {
const now = Date.now();
if (now - lastActivity < 300) return;
lastActivity = now;
if (visible && now - showedAt > 500) hide();
resetIdleTimer();
}
// ── Init ──
function init() {
const overlay = $('watch-overlay');
if (!overlay) return;
overlay.addEventListener('click', () => { hide(); onActivity(); });
window.addEventListener('keydown', e => {
if (e.key === 'Escape' && visible) {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
hide(); onActivity();
}
}, { capture: true });
for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) {
window.addEventListener(ev, onActivity, { passive: true });
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') onActivity();
});
resetIdleTimer();
window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' });
}
window._watchToggle = toggle;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+15 -12
View File
@@ -2,9 +2,8 @@
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
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.
encryption secret, and their own inbox. Cards and encrypted chat
messages can be sent **between tenants** on the same relay.
The relay is a single-file PHP app — drop the directory into any PHP
webspace and it works.
@@ -55,9 +54,9 @@ The admin only ever touches secrets #1 and #3. The user only ever touches secret
The relay **does** see plaintext briefly during the cross-tenant hop
(steps 2–3 and 5–6). This is the same trust model as email: you trust
the relay operator. **Strict end-to-end encryption** for the planned
chat feature is on the roadmap and will require a per-pair key exchange
(Curve25519/ECDH) — this is documented as future work in `index.php`.
the relay operator. Chat between tenants is shipped (via the chat
plugin with delivery + read receipts). **Strict end-to-end encryption**
with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
For at-rest data, all sensitive values are encrypted with the master
key, so a stolen DB without the master key file gives an attacker
@@ -155,7 +154,7 @@ incoming messages.
### 6. Send a card to another tenant
(currently in the relay UI; hyprcrd-side recipient picker is coming)
(in the relay UI compose tab)
In the relay UI **compose** tab:
- Pick a recipient from the dropdown (defaults to yourself)
@@ -166,8 +165,7 @@ The recipient will see it on their next hyprcrd poll, materialized as a
card in their board.
You can also right-click a card in hyprcrd and pick **push to relay** —
this currently sends to your own inbox; recipient picker on the hyprcrd
side is on the TODO.
this sends to your own inbox (or pick a recipient in the relay UI).
## Data lifetime
@@ -226,6 +224,10 @@ All endpoints under `/api/`. JSON in/out.
| GET | `/api/me` | — | Current tenant info |
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
| GET | `/api/messages/inbox` | — | Read own inbox messages (decrypted for display) |
| DELETE | `/api/messages/{id}` | — | Delete a message from own inbox |
| GET | `/api/chat/sent` | — | Sent chat messages with delivery/read status |
| POST | `/api/chat/seen` | `{ids: [...]}` | Mark chat messages as seen (read receipts) |
### Admin only (session + `is_admin`)
@@ -302,8 +304,7 @@ use the same code path.
- A compromised relay machine = compromised messages. Use HTTPS, keep
PHP up to date, restrict shell access.
**Future work for stronger guarantees** (planned for the encrypted chat
feature):
**Future work for stronger guarantees** (roadmap for strict E2E):
- Per-tenant Curve25519 key pair stored in the DB (public key plus
master-key-encrypted private key, or even private key never on the
server)
@@ -522,7 +523,9 @@ relay/
│ └── routes/
│ ├── public.php # /api/status, /setup, /login, /logout, /me
│ ├── admin.php # /api/users CRUD, /api/tenants directory
│ └── messages.php # /api/messages, /api/attachments, cross-tenant routing
│ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
├── ui/
│ ├── layout.php # HTML shell + view containers
│ ├── style.css # all CSS (including responsive media queries)
+41
View File
@@ -189,8 +189,31 @@ $chatColNames = array_column($chatCols, 'name');
if (!in_array('seen', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
}
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
// the chat thread. The feature moved to its own `relay_sync_events` queue
// (see below). Drop any leftover bot rows so they stop showing up in
// users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs
// a full table rebuild — not worth it for an ignored nullable column).
if (in_array('bot', $chatColNames)) {
$db->exec('DELETE FROM chat_messages WHERE bot = 1');
}
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
// Sync-event notifications: replaces the old bot-chat mechanism. When a
// client syncs a card they received, they POST here; the sender polls
// GET /api/sync-events and surfaces each as a transient toast. Rows are
// short-lived — we drop fetched ones after an hour, unfetched after a day.
$db->exec('CREATE TABLE IF NOT EXISTS relay_sync_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_id INTEGER NOT NULL,
sender_username TEXT NOT NULL,
body TEXT NOT NULL,
fetched INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_sync_events_recipient ON relay_sync_events(recipient_id, id)');
// Collaborative buckets: shared among multiple tenants. Each bucket has one
// owner and a membership set. Buckets persist (not ephemeral) since they are
// the container for ongoing collaboration — cleanup only when deleted.
@@ -232,6 +255,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
// Collab attachments: binary assets referenced by collab cards (images,
// PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to
// a bucket — all members of the bucket can fetch. Filename is the
// hex-hash name that appears in card bodies as uploads/<name>.
$db->exec('CREATE TABLE IF NOT EXISTS collab_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_uid TEXT NOT NULL,
filename TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (bucket_uid, filename)
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)');
// Phase 4: advisory edit locks on collab cards. A client that opens the
// editor for a collab card heartbeats into this table every 30s with a
// 60s TTL. Other clients poll the list and show a lock indicator on the
@@ -280,6 +317,10 @@ $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
// Chat: 7 days, longer than card sync but still ephemeral
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')");
// Sync events are transient toasts — drop fetched ones after an hour,
// everything after 24h regardless (sender was offline too long to care).
$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')");
$db->exec("DELETE FROM relay_sync_events WHERE created_at < datetime('now', '-24 hours')");
// Collab-card tombstones: 7 days so late pollers still see the delete.
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
// Collab-card locks: drop everything past its TTL on every request.
+5
View File
@@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
if ($id === (int)$sessionTenant['id']) {
jsonResponse(['error' => 'cannot delete yourself'], 400);
}
// chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
// SET NULL — that leaves sender_username populated so the deleted user
// keeps showing up as a chat thread for their peers. Purge those rows
// explicitly BEFORE the tenant delete, while sender_id is still matchable.
$db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
jsonResponse(['ok' => true]);
}
+135 -2
View File
@@ -17,10 +17,143 @@
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// $clientTenant is set by messages.php earlier in the chain via authClient().
// We re-resolve here to keep this file self-contained.
// ── Session-based chat (relay UI) ──
if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$myId = $sessionTenant['id'];
$stmt = $db->prepare('
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
FROM chat_messages WHERE recipient_id = :r
GROUP BY sender_username
UNION
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
WHERE cm.sender_id = :s AND cm.recipient_id != :s2
GROUP BY t.username
');
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
$rows = $stmt->fetchAll();
$threads = [];
foreach ($rows as $row) {
$peer = $row['peer'];
if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0];
$threads[$peer]['count'] += (int)$row['count'];
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
}
usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
// Add unread count per peer
foreach ($threads as &$t) {
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
$t['unread'] = (int)$stmt2->fetchColumn();
}
jsonResponse(array_values($threads));
}
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
$peer = trim($_GET['peer'] ?? '');
if (!$peer) jsonResponse(['error' => 'peer required'], 400);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$myId = $sessionTenant['id'];
$peerTenant = getTenantByUsername($db, $peer);
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
$peerId = $peerTenant['id'];
$stmt = $db->prepare('
SELECT id, sender_id, sender_username, body, created_at, seen
FROM chat_messages
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
ORDER BY created_at ASC LIMIT 500
');
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int)$row['id'],
'from' => $row['sender_username'],
'mine' => (int)$row['sender_id'] === $myId,
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'created_at' => $row['created_at'],
'seen' => (int)$row['seen'],
];
}
// Mark incoming as seen
$db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0')
->execute(['r' => $myId, 'p' => $peerId]);
jsonResponse($out);
}
if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$body = trim((string)($input['body'] ?? ''));
$recipientName = trim((string)($input['recipient'] ?? ''));
if ($body === '') jsonResponse(['error' => 'body required'], 400);
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stored = clientEncrypt($body, $interKey);
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
// ── Client API ──
$clientTenant = $clientTenant ?? authClient($db);
// Sync-event notifications. Client API only: a tenant that just synced
// a received card POSTs here to announce it to the original sender, and
// the sender polls GET to surface each event as a transient toast.
// Bodies are short plaintext strings like "alice synced: Card Title" —
// no encryption, no per-peer routing through the chat thread, no
// clutter in the regular chat history.
if ($route === '/api/sync-events' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$body = trim((string)($input['body'] ?? ''));
$recipientName = trim((string)($input['recipient'] ?? ''));
if ($body === '') jsonResponse(['error' => 'body required'], 400);
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if ($route === '/api/sync-events' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$stmt = $db->prepare('
SELECT id, sender_username, body, created_at
FROM relay_sync_events
WHERE recipient_id = :r AND fetched = 0
ORDER BY id ASC
LIMIT 100
');
$stmt->execute(['r' => $clientTenant['id']]);
$rows = $stmt->fetchAll();
if ($rows) {
$maxId = (int)$rows[count($rows) - 1]['id'];
$db->prepare('UPDATE relay_sync_events SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
}
$out = array_map(fn($r) => [
'id' => (int)$r['id'],
'sender_username' => $r['sender_username'],
'body' => $r['body'],
'created_at' => $r['created_at'],
], $rows);
jsonResponse(['events' => $out]);
}
if ($route === '/api/chat' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
+104
View File
@@ -105,6 +105,47 @@ if ($route === '/api/collab-cards' && $method === 'POST') {
// ── GET: pull deltas since a given timestamp ─────────────────────
// ── Session-auth summary for the relay UI ─────────────────────
// Lets the relay's web UI surface collab bucket activity to the
// logged-in tenant without exposing the per-client encryption dance:
// decrypts payloads with the inter-tenant key (we're authorizing via
// the PHP session, so there's no per-request client secret). Returns
// just title + small body preview + author + bucket so the UI can list
// recent activity like an inbox.
if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) {
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stmt = $db->prepare('
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted,
b.name AS bucket_name, b.color AS bucket_color
FROM collab_cards c
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid
WHERE m.tenant_id = :tid AND c.deleted = 0
ORDER BY c.updated_at DESC
LIMIT 200
');
$stmt->execute(['tid' => $sessionTenant['id']]);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
$fields = $plain ? (json_decode($plain, true) ?: []) : [];
$body = (string)($fields['body'] ?? '');
$out[] = [
'bucket_uid' => $row['bucket_uid'],
'bucket_name' => $row['bucket_name'],
'bucket_color' => $row['bucket_color'] ?? '#38bdf8',
'card_uid' => $row['card_uid'],
'author_username' => $row['author_username'],
'title' => (string)($fields['title'] ?? ''),
'preview' => mb_substr(trim(strip_tags($body)), 0, 140),
'updated_at' => $row['updated_at'],
'mine' => $row['author_username'] === $sessionTenant['username'],
];
}
jsonResponse(['ok' => true, 'items' => $out]);
}
if ($route === '/api/collab-cards' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
@@ -199,3 +240,66 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
]);
jsonResponse(['ok' => true]);
}
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
if ($route === '/api/collab-attachments' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$bucketUid = (string)($input['bucket_uid'] ?? '');
$filename = (string)($input['filename'] ?? '');
$data = (string)($input['data'] ?? '');
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
jsonResponse(['error' => 'not a member of this bucket'], 403);
}
// Decrypt-reencrypt hop: data arrives encrypted with sender's secret,
// stored at rest under the inter-tenant key.
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'sender has no client secret'], 400);
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$plain = clientDecrypt($data, $senderSecret);
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
$stored = clientEncrypt($plain, $interKey);
$db->prepare('INSERT INTO collab_attachments (bucket_uid, filename, data) VALUES (:b, :f, :d)
ON CONFLICT(bucket_uid, filename) DO UPDATE SET data = excluded.data')
->execute(['b' => $bucketUid, 'f' => $filename, 'd' => $stored]);
jsonResponse(['ok' => true]);
}
if ($route === '/api/collab-attachments' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$bucketUid = trim($_GET['bucket_uid'] ?? '');
$filename = trim($_GET['filename'] ?? '');
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
jsonResponse(['error' => 'not a member of this bucket'], 403);
}
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'no client secret set'], 400);
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
if ($filename) {
$stmt = $db->prepare('SELECT filename, data FROM collab_attachments WHERE bucket_uid = :b AND filename = :f');
$stmt->execute(['b' => $bucketUid, 'f' => $filename]);
} else {
// List all filenames for this bucket (no data — client picks what to fetch)
$stmt = $db->prepare('SELECT filename FROM collab_attachments WHERE bucket_uid = :b ORDER BY created_at DESC');
$stmt->execute(['b' => $bucketUid]);
jsonResponse(['ok' => true, 'files' => array_column($stmt->fetchAll(), 'filename')]);
}
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $row) {
$plain = clientDecrypt($row['data'] ?? '', $interKey);
$out[] = ['filename' => $row['filename'], 'data' => clientEncrypt($plain, $recipientSecret)];
}
jsonResponse(['ok' => true, 'attachments' => $out]);
}
+61 -4
View File
@@ -27,6 +27,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$storedTitle = clientEncrypt($title, $interKey);
$storedBody = clientEncrypt($body, $interKey);
$relayUid = $input['relay_uid'] ?? bin2hex(random_bytes(16));
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
->execute([
@@ -37,9 +38,43 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
'b' => $storedBody,
'c' => $input['color'] ?? '#38bdf8',
'tg' => $input['tags'] ?? '',
'uid' => $input['relay_uid'] ?? null,
'uid' => $relayUid,
]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName, 'relay_uid' => $relayUid]);
}
// ── UI attachment upload: session stores files encrypted at rest ──
if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant) {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'no file'], 400);
}
$relayUid = $_POST['relay_uid'] ?? '';
$recipientName = trim($_POST['recipient'] ?? $sessionTenant['username']);
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
// Generate hex filename so hyprcrd's poll handler accepts it
$origName = basename($_FILES['file']['name']);
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
$hexName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
$plainBytes = file_get_contents($_FILES['file']['tmp_name']);
// Store as base64(raw) encrypted — matches client API format.
// The GET endpoint re-encrypts for recipient; hyprcrd decrypts
// and base64_decodes to get the original file bytes.
$storedData = clientEncrypt(base64_encode($plainBytes), $interKey);
$db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName]);
$db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName, 'data' => $storedData]);
jsonResponse(['ok' => true, 'filename' => $hexName, 'original' => $origName]);
}
// ── UI inbox view: session reads decrypted messages ──
@@ -71,6 +106,28 @@ if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
jsonResponse(['messages' => $out]);
}
// ── UI sent view: messages this user sent to others ──
if ($route === '/api/messages/sent' && $method === 'GET' && $sessionTenant) {
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stmt = $db->prepare('SELECT m.id, m.title, m.body, m.created_at, m.fetched, t.username AS recipient FROM messages m JOIN tenants t ON t.id = m.recipient_id WHERE m.sender_id = :s AND m.recipient_id != :s2 ORDER BY m.created_at DESC LIMIT 200');
$stmt->execute(['s' => $sessionTenant['id'], 's2' => $sessionTenant['id']]);
$rows = $stmt->fetchAll();
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int)$row['id'],
'recipient' => $row['recipient'] ?? '?',
'title' => clientDecrypt($row['title'] ?? '', $interKey),
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'created_at' => $row['created_at'],
'fetched' => (int)$row['fetched'],
];
}
jsonResponse(['messages' => $out]);
}
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
$clientTenant = authClient($db);
@@ -155,8 +212,8 @@ if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
$tenant = $clientTenant ?: $sessionTenant;
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$id = (int)$m[1];
$db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
->execute(['id' => $id, 'r' => $tenant['id']]);
$db->prepare('DELETE FROM messages WHERE id = :id AND (recipient_id = :r OR sender_id = :s)')
->execute(['id' => $id, 'r' => $tenant['id'], 's' => $tenant['id']]);
jsonResponse(['ok' => true]);
}
+371 -29
View File
@@ -68,14 +68,15 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
if (['compose', 'account', 'admin'].includes(name)) {
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose');
$('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin');
$('nav-burger')?.classList.remove('hidden');
@@ -91,11 +92,19 @@ function showView(name) {
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
loadInbox();
loadOutbox();
loadCollabInbox();
startInboxPoll();
$('compose-body')?.focus();
} else {
stopInboxPoll();
}
if (name === 'chat') {
loadChatThreads();
startChatPoll();
} else {
stopChatPoll();
}
if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
@@ -113,16 +122,30 @@ function showView(name) {
// the moment the user navigates away (account/admin/login) so we don't
// hammer the server in the background.
let inboxPollHandle = null;
let inboxBusy = false;
function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
if (inboxBusy) return;
if (!$('view-compose')?.classList.contains('hidden')) {
loadInbox({ quiet: true });
loadOutbox({ quiet: true });
loadCollabInbox({ quiet: true });
}
}, 15000);
}
function stopInboxPoll() {
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
}
// Suppress poll re-renders while the user is typing in any input/textarea
document.addEventListener('focusin', e => {
if (e.target.matches('input, textarea, select')) inboxBusy = true;
});
document.addEventListener('focusout', e => {
if (e.target.matches('input, textarea, select')) inboxBusy = false;
});
/** Burger toggle for the mobile nav. The button is hidden on desktop
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
* positioned under the header. */
@@ -323,6 +346,7 @@ function populateRecipientPicker() {
hidden.value = opt.dataset.value;
label.textContent = opt.textContent.trim();
dd.classList.add('hidden');
updateCcSelfVisibility();
});
});
@@ -335,6 +359,17 @@ function populateRecipientPicker() {
// Solo tenant → hide the picker
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
updateCcSelfVisibility();
}
let composeFiles = [];
function updateCcSelfVisibility() {
const wrap = $('compose-cc-self-wrap');
if (!wrap) return;
const recipient = $('compose-recipient')?.value || '';
const isSelf = !recipient || (me && recipient === me.username);
wrap.style.display = isSelf ? 'none' : '';
}
function clearCompose() {
@@ -342,27 +377,140 @@ function clearCompose() {
$('compose-body').value = '';
$('compose-body').focus();
setComposeStatus('');
composeFiles = [];
renderComposeAttachments();
}
$('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click());
$('compose-file')?.addEventListener('change', e => {
for (const f of e.target.files) {
if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue;
composeFiles.push(f);
}
e.target.value = '';
renderComposeAttachments();
});
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function fileIcon(name) {
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image';
if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note';
if (/\.pdf$/i.test(name)) return 'ph-file-pdf';
return 'ph-file';
}
function renderComposeAttachments() {
const el = $('compose-attachments');
if (!el) return;
el.innerHTML = composeFiles.map((f, i) => `
<div class="compose-att">
<i class="ph ${fileIcon(f.name)} att-icon"></i>
<span>${escapeHtml(f.name)}</span>
<span class="att-size">${formatSize(f.size)}</span>
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
</div>
`).join('');
el.querySelectorAll('.att-remove').forEach(btn => {
btn.addEventListener('click', () => {
composeFiles.splice(parseInt(btn.dataset.idx), 1);
renderComposeAttachments();
});
});
}
let composeStatusTimer = null;
function setComposeStatus(text, kind = '') {
clearTimeout(composeStatusTimer);
const el = $('compose-status');
if (!el) return;
el.textContent = text;
el.className = 'status ' + kind;
el.className = 'compose-status-bar' + (kind ? ' ' + kind : '');
if (text) {
el.classList.add('visible');
if (kind === 'ok') {
composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000);
}
} else {
el.classList.remove('visible');
}
}
function randomHex(n) {
const a = new Uint8Array(n);
crypto.getRandomValues(a);
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
async function sendCompose() {
const title = $('compose-title').value;
const body = $('compose-body').value;
let body = $('compose-body').value;
const recipient = $('compose-recipient').value;
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; }
const relayUid = randomHex(16);
const hasFiles = composeFiles.length > 0;
if (hasFiles) {
setComposeStatus('uploading files...');
const hexNames = [];
for (const f of composeFiles) {
const form = new FormData();
form.append('file', f);
form.append('relay_uid', relayUid);
form.append('recipient', recipient || '');
try {
const r = await fetch('api/attachments/upload', { method: 'POST', body: form });
const data = await r.json();
if (data.ok && data.filename) {
const ext = (data.original || f.name).split('.').pop().toLowerCase();
const isImg = /^(png|jpe?g|gif|webp)$/.test(ext);
const label = data.original || f.name;
hexNames.push(isImg
? `![${label}](uploads/${data.filename})`
: `[📄 ${label}](uploads/${data.filename})`);
}
} catch {}
}
if (hexNames.length) {
body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n');
}
}
setComposeStatus('sending...');
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
const ccSelf = $('compose-cc-self')?.checked && recipient;
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } });
if (res.ok) {
setComposeStatus(`sent to ${res.recipient}`, 'ok');
// Send a copy to self tagged with recipient name
if (ccSelf) {
const ccTag = `#sent/${res.recipient}`;
const ccBody = body ? body + '\n\n' + ccTag : ccTag;
const ccUid = randomHex(16);
await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } });
// Also copy attachments to the self-message
if (hasFiles) {
for (const f of composeFiles) {
const form = new FormData();
form.append('file', f);
form.append('relay_uid', ccUid);
form.append('recipient', '');
try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {}
}
}
}
setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok');
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-cc-self').checked = false;
composeFiles = [];
renderComposeAttachments();
$('compose-body').focus();
loadInbox();
loadOutbox();
refreshMe();
} else {
setComposeStatus(res.error || 'failed', 'err');
@@ -371,18 +519,27 @@ async function sendCompose() {
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
async function loadInbox() {
let lastInboxJson = '';
async function loadInbox({ quiet = false } = {}) {
const list = $('inbox-list');
list.innerHTML = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
if (!quiet) {
list.innerHTML = `
<div class="skeleton-row"><div class="skel tall"></div></div>
<div class="skeleton-row"><div class="skel short"></div></div>
<div class="skeleton-row"><div class="skel"></div></div>
`;
}
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
// In quiet mode, skip DOM update if nothing changed
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
if (quiet && json === lastInboxJson) return;
lastInboxJson = json;
$('inbox-count').textContent = messages.length;
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
// Mirror count into the header status so the unread dot stays in sync
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
$('header-status').classList.toggle('has-unread', messages.length > 0);
@@ -407,7 +564,79 @@ async function loadInbox() {
`).join('');
}
async function deleteMessage(id, btn) {
let lastOutboxJson = '';
async function loadOutbox({ quiet = false } = {}) {
const list = $('outbox-list');
if (!list) return;
const res = await api('api/messages/sent');
const messages = res?.messages || [];
const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched));
if (quiet && json === lastOutboxJson) return;
lastOutboxJson = json;
$('outbox-count').textContent = messages.length;
if (!messages.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-paper-plane-tilt icon"></i>
<div class="label">no sent messages</div>
<div class="hint">Messages you send to other tenants will appear here.</div>
</div>`;
return;
}
list.innerHTML = messages.map(m => `
<div class="inbox-item" data-id="${m.id}">
<div class="content">
<div class="from"><i class="ph ph-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</span>'}</div>
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
</div>
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
<button class="del" onclick="deleteMessage(${m.id}, this, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
</div>
`).join('');
}
// ── Shared buckets / collab inbox ──
let lastCollabJson = '';
async function loadCollabInbox({ quiet = false } = {}) {
const list = $('shared-list');
const countEl = $('shared-count');
if (!list) return;
const res = await api('api/collab-inbox');
const items = res?.items || [];
const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at));
if (quiet && json === lastCollabJson) return;
lastCollabJson = json;
if (countEl) countEl.textContent = items.length;
if (!items.length) {
list.innerHTML = `
<div class="empty-state">
<i class="ph ph-users-three icon"></i>
<div class="label">no shared bucket activity</div>
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
</div>`;
return;
}
list.innerHTML = items.map(c => `
<div class="inbox-item">
<div class="content">
<div class="from">
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
</div>
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
${c.preview ? `<div style="color:var(--dim);font-size:11px;margin-top:2px;">${escapeHtml(c.preview)}</div>` : ''}
</div>
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
</div>
`).join('');
}
async function deleteMessage(id, btn, source) {
inboxBusy = true;
// Inline confirmation: replace the trash button with yes/no.
// Event listeners are wired via JS (not inline onclick) to avoid
// passing escaped HTML through attributes.
@@ -425,9 +654,10 @@ async function deleteMessage(id, btn) {
btn.replaceWith(wrap);
wrap.querySelector('.del-yes').addEventListener('click', async () => {
const res = await api('api/messages/' + id, { method: 'DELETE' });
inboxBusy = false;
if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
loadInbox();
if (source === 'outbox') loadOutbox(); else loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
@@ -435,6 +665,7 @@ async function deleteMessage(id, btn) {
}
});
wrap.querySelector('.del-no').addEventListener('click', () => {
inboxBusy = false;
const temp = document.createElement('div');
temp.innerHTML = origHtml;
wrap.replaceWith(temp.firstChild);
@@ -443,24 +674,135 @@ async function deleteMessage(id, btn) {
window.deleteMessage = deleteMessage;
async function deleteAllMessages() {
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
if (!messages.length) return;
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
let ok = 0;
for (const m of messages) {
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
if (r?.ok) ok++;
}
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
inboxBusy = true;
try {
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
if (!messages.length) return;
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
let ok = 0;
for (const m of messages) {
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
if (r?.ok) ok++;
}
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
} finally { inboxBusy = false; }
}
window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */
// ── Chat ──
let chatActivePeer = null;
let chatPollHandle = null;
function startChatPoll() {
stopChatPoll();
chatPollHandle = setInterval(() => {
loadChatThreads(true);
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
}, 5000);
}
function stopChatPoll() {
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
}
async function loadChatThreads(quiet = false) {
const list = $('chat-thread-list');
if (!list) return;
const threads = await api('api/chat/threads') || [];
if (!Array.isArray(threads)) return;
// If no threads and not quiet, show empty state
if (!threads.length && !quiet) {
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
return;
}
if (!threads.length) return;
list.innerHTML = threads.map(t => `
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div class="peer-name">${escapeHtml(t.peer)}</div>
<div class="thread-time">${formatRelTime(t.last_at)}</div>
</div>
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
</div>
`).join('');
list.querySelectorAll('.chat-thread').forEach(el => {
el.addEventListener('click', () => {
chatActivePeer = el.dataset.peer;
loadChatThreads(true);
loadChatHistory(chatActivePeer);
$('chat-input-bar')?.classList.remove('hidden');
$('chat-peer-name').textContent = chatActivePeer;
$('chat-input')?.focus();
});
});
}
async function loadChatHistory(peer, quiet = false) {
const container = $('chat-messages');
if (!container) return;
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
if (!Array.isArray(messages)) return;
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
if (!messages.length) {
container.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
return;
}
container.innerHTML = messages.map(m => `
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
<div>${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
</div>
`).join('');
if (wasAtBottom || !quiet) {
container.scrollTop = container.scrollHeight;
}
// Refresh thread list to clear unread badges
if (!quiet) loadChatThreads(true);
}
async function sendChatMessage() {
const input = $('chat-input');
if (!input || !chatActivePeer) return;
const body = input.value.trim();
if (!body) return;
input.value = '';
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
loadChatHistory(chatActivePeer);
loadChatThreads(true);
}
$('chat-send-btn')?.addEventListener('click', sendChatMessage);
$('chat-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
});
function formatChatTime(sqliteUtc) {
if (!sqliteUtc) return '';
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
if (isNaN(t.getTime())) return sqliteUtc;
const now = new Date();
const time = t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (t.toDateString() === now.toDateString()) return time;
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);
if (t.toDateString() === yesterday.toDateString()) return 'yesterday ' + time;
return t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
}
function formatRelTime(sqliteUtc) {
if (!sqliteUtc) return '';
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
+46 -7
View File
@@ -22,6 +22,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<div id="nav-buttons" class="hidden">
<span id="nav-user" class="user"></span>
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
@@ -91,19 +92,35 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<label>body</label>
<textarea id="compose-body" placeholder="write something..."></textarea>
</div>
<div id="compose-attachments" class="compose-attachments"></div>
<label class="checkbox" id="compose-cc-self-wrap" style="margin-top:4px;">
<input type="checkbox" id="compose-cc-self"> keep a copy (tagged with recipient name)
</label>
<div class="actions">
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
<button id="compose-attach-btn" class="btn btn-secondary"><i class="ph ph-paperclip"></i> attach</button>
<input type="file" id="compose-file" multiple class="hidden">
<span class="spacer"></span>
<span id="compose-status" class="status"></span>
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
</div>
<div id="compose-status" class="compose-status-bar"></div>
</div>
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
<span style="flex:1"></span>
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
</h2>
<div id="inbox-list"></div>
<div id="compose-sidebar" class="compose-sidebar">
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
<span style="flex:1"></span>
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
</h2>
<div id="inbox-list"></div>
</div>
<div id="shared-section" class="section">
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
<div id="shared-list"></div>
</div>
<div id="outbox-section" class="section">
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
<div id="outbox-list"></div>
</div>
</div>
</div>
@@ -193,6 +210,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</div>
</div>
<!-- ── Chat view ── -->
<div id="view-chat" class="hidden">
<div class="chat-layout">
<div class="chat-sidebar">
<div class="chat-sidebar-header">
<span class="pixel" style="font-size:10px;color:var(--accent);">conversations</span>
</div>
<div id="chat-thread-list"></div>
</div>
<div class="chat-main">
<div id="chat-header" class="chat-header">
<span id="chat-peer-name" class="pixel" style="font-size:11px;color:var(--bright);">select a conversation</span>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div id="chat-input-bar" class="chat-input-bar hidden">
<input type="text" id="chat-input" placeholder="type a message..." autocomplete="off">
<button id="chat-send-btn" class="btn btn-primary" style="padding:6px 14px;"><i class="ph ph-paper-plane-tilt"></i></button>
</div>
</div>
</div>
</div>
</main>
<!-- Confirm dialog (replaces native confirm()) -->
+91 -1
View File
@@ -232,10 +232,78 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
#view-compose:not(.hidden) {
display: flex; flex-direction: column; gap: 16px;
}
.compose-attachments {
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
}
.compose-attachments:empty { display: none; }
.compose-att {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: var(--radius);
background: var(--raised); border: 1px solid var(--border);
font-family: var(--pixel); font-size: 9px; color: var(--text);
}
.compose-att .att-remove {
background: none; border: none; color: var(--muted); cursor: pointer;
padding: 0; font-size: 11px; line-height: 1;
transition: color 0.15s;
}
.compose-att .att-remove:hover { color: var(--danger); }
.compose-att .att-icon { font-size: 12px; color: var(--dim); }
.compose-att .att-size { color: var(--dim); }
.compose-card .actions {
display:flex; gap:8px; align-items:center;
}
.compose-card .actions .spacer { flex:1; }
/* Chat view */
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
.chat-sidebar-header { padding: 12px; border-bottom: 1px solid var(--border); }
#chat-thread-list { flex: 1; overflow-y: auto; }
.chat-thread {
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s;
}
.chat-thread:hover { background: var(--raised); }
.chat-thread.active { background: var(--raised); border-left: 3px solid var(--accent); }
.chat-thread .peer-name { font-family: var(--pixel); font-size: 10px; color: var(--bright); flex: 1; }
.chat-thread .unread-badge {
font-family: var(--pixel); font-size: 8px; background: var(--accent);
color: var(--bg); padding: 1px 6px; border-radius: 8px; min-width: 16px; text-align: center;
}
.chat-thread .thread-time { font-family: var(--pixel); font-size: 8px; color: var(--dim); }
.chat-main { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.chat-header { padding: 12px 16px; border-bottom: 1px solid var(--border); }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; }
.chat-msg {
max-width: 75%; padding: 8px 12px; border-radius: var(--radius-lg);
font-size: 12px; line-height: 1.6; word-break: break-word;
}
.chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; }
.chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
.chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
.chat-msg.mine .msg-time { text-align: right; }
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
.chat-input-bar input {
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--bright);
padding: 8px 12px; font-size: 12px; font-family: var(--mono);
border-radius: var(--radius); outline: none;
}
.chat-input-bar input:focus { border-color: var(--accent); }
.chat-empty {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 8px; color: var(--dim);
font-family: var(--pixel); font-size: 10px;
}
.compose-status-bar {
font-family: var(--pixel); font-size: 10px; color: var(--muted);
min-height: 0; overflow: hidden;
transition: opacity 0.3s ease, max-height 0.3s ease;
max-height: 0; opacity: 0;
}
.compose-status-bar.visible { max-height: 32px; opacity: 1; padding-top: 8px; }
.compose-status-bar.ok { color: var(--accent); }
.compose-status-bar.err { color: var(--danger); }
/* Status text (used in compose, account, create-user) */
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
@@ -564,6 +632,22 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
}
#view-admin .section h2 { margin-bottom:10px; }
#view-admin .section .create-user-form { margin-top:14px; }
.chat-layout { flex-direction: column; height: calc(100vh - 160px); }
.chat-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
#chat-thread-list {
display: flex; overflow-x: auto; overflow-y: hidden; gap: 0;
scrollbar-width: none; -ms-overflow-style: none;
}
#chat-thread-list::-webkit-scrollbar { display: none; }
.chat-thread {
flex-shrink: 0; flex-direction: column; align-items: center;
gap: 2px; padding: 8px 14px; border-bottom: none; border-right: 1px solid var(--border);
min-width: 70px; text-align: center;
}
.chat-thread.active { border-left: none; border-bottom: 3px solid var(--accent); }
.chat-thread .thread-time { display: none; }
.chat-sidebar-header { display: none; }
}
/* ─────────────────────────────────────────────────────────────────
@@ -603,13 +687,19 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
border-radius: var(--radius);
}
/* Compose view: 2-column grid */
/* Compose view: 2 columns. Left is the compose editor. Right is a
sidebar with inbox → shared → sent stacked vertically. */
#view-compose:not(.hidden) {
display: grid !important;
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
#compose-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Cards: depth via shadow + larger padding */
.compose-card,
+49 -4
View File
@@ -171,6 +171,12 @@ class CalDavSync
$time = $zm[1];
$timeEnd = $zm[2] ?? '';
}
// Multi-day: **Bis:** 2026-04-20 or **Bis:** 2026-04-20 18:00
$endDate = ''; $endTime = '';
if (preg_match('/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?/i', $body, $bm)) {
$endDate = $bm[1];
$endTime = $bm[2] ?? '';
}
$location = '';
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
$description = '';
@@ -178,9 +184,19 @@ class CalDavSync
// Build DTSTART/DTEND
$dateCompact = str_replace('-', '', $date);
$endDateCompact = $endDate ? str_replace('-', '', $endDate) : '';
if ($time) {
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
if ($timeEnd) {
if ($endDate) {
// Multi-day with time — use end date + end time (or start time + 1h)
$useEndTime = $endTime ?: $time;
if (!$endTime) {
$ts = strtotime($endDate . ' ' . $useEndTime);
$dtend = date('Ymd\THis', $ts + 3600);
} else {
$dtend = $endDateCompact . 'T' . str_replace(':', '', $endTime) . '00';
}
} elseif ($timeEnd) {
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
} else {
// Default: +1 hour
@@ -189,8 +205,13 @@ class CalDavSync
}
} else {
$dtstart = $dateCompact;
// All-day: DTEND = next day
$dtend = date('Ymd', strtotime($date . ' +1 day'));
if ($endDate) {
// Multi-day all-day: DTEND is exclusive, so +1 day after end
$dtend = date('Ymd', strtotime($endDate . ' +1 day'));
} else {
// Single-day all-day: DTEND = next day
$dtend = date('Ymd', strtotime($date . ' +1 day'));
}
}
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
@@ -224,12 +245,22 @@ class CalDavSync
$time = '';
$timeEnd = '';
$endDate = '';
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
$time = "{$hm[1]}:{$hm[2]}";
}
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
}
// Detect multi-day event: dtend's date > dtstart's date
if (!empty($vevent['dtend']) && preg_match('/^(\d{4})(\d{2})(\d{2})/', $vevent['dtend'], $edm)) {
$endIso = "{$edm[1]}-{$edm[2]}-{$edm[3]}";
// For all-day events, dtend is exclusive — subtract one day
if (!$time && !empty($vevent['dtend']) && strpos($vevent['dtend'], 'T') === false) {
$endIso = date('Y-m-d', strtotime($endIso . ' -1 day'));
}
if ($endIso > $date) $endDate = $endIso;
}
$summary = $vevent['summary'] ?? 'Termin';
$title = $summary ? "{$date} — {$summary}" : $date;
@@ -244,7 +275,8 @@ class CalDavSync
}
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
$body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
$bisLine = $endDate ? ("\n**Bis:** " . $endDate . ($timeEnd && $endDate ? ' ' . $timeEnd : '')) : '';
$body = "**Zeit:** {$zeitLine}{$bisLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
// Color: birthdays pink, others orange
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
@@ -381,11 +413,16 @@ class CalDavSync
$remoteMap = []; // uid -> event data (gets modified during reconciliation)
$remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups)
$remoteEvents = [];
$fetchFailed = false;
foreach ($urls['all'] as $calUrl) {
$calSlug = basename(rtrim($calUrl, '/'));
$cal = $this->makeClient($calUrl);
$res = $cal->request('REPORT', $calUrl, '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter></c:calendar-query>', ['Depth: 1']);
$this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}");
if ($res['status'] != 207) {
$fetchFailed = true;
continue;
}
$events = $cal->listEvents();
$this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}");
$parseFail = 0;
@@ -403,6 +440,14 @@ class CalDavSync
if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}");
}
// Abort on fetch failure — an incomplete remote list would make every
// local card look remotely-deleted and wipe them all. Better to sync
// nothing than to destroy local data on a flaky connection.
if ($fetchFailed) {
$this->addLog('sync', null, null, 'abort', 'error', 'remote fetch failed — skipping reconciliation to protect local data');
return $this->stats;
}
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
// 3. Fetch local appointment cards — only #termin tagged or already synced
+18
View File
@@ -151,5 +151,23 @@ class Database
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
}
if (!in_array('scratchpad', $colNames)) {
self::$pdo->exec("ALTER TABLE cards ADD COLUMN scratchpad TEXT DEFAULT NULL");
}
// Card edit history
self::$pdo->exec('
CREATE TABLE IF NOT EXISTS card_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
)
');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
}
}
+702
View File
@@ -0,0 +1,702 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hyprcrd</title>
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #06080f;
--s1: #0d1117;
--s2: #161b22;
--bdr: #21262d;
--tx: #8b949e;
--hi: #e6edf3;
--ac: #38bdf8;
--ac2: #a78bfa;
--rd: #fb7185;
--gn: #34d399;
--yl: #fbbf24;
--pk: #f472b6;
--pixel: 'Silkscreen', monospace;
--mono: 'JetBrains Mono', monospace;
}
html { scroll-behavior: smooth; }
body { font-family: var(--mono); background: var(--bg); color: var(--tx); }
::selection { background: rgba(56,189,248,0.25); }
/* ── Top bar ── */
header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(6,8,15,0.88); backdrop-filter: blur(16px);
border-bottom: 1px solid var(--bdr); padding: 0 48px;
}
header .inner {
display: flex; align-items: center; height: 48px; gap: 20px;
}
header .brand { font-family: var(--pixel); font-size: 12px; color: var(--ac); letter-spacing: 2px; }
header nav { margin-left: auto; display: flex; gap: 20px; align-items: center; }
header nav a { font-size: 11px; color: var(--tx); font-family: var(--pixel); text-decoration: none; }
header nav a:hover { color: var(--hi); }
.gh-btn {
font-family: var(--pixel); font-size: 10px; color: var(--bg);
background: var(--ac); padding: 5px 16px; border-radius: 4px;
}
.gh-btn:hover { background: #7dd3fc; }
/* ── Sections ── */
.section {
min-height: 100vh; padding: 80px 48px;
display: flex; flex-direction: column; justify-content: center;
border-bottom: 1px solid var(--bdr);
position: relative; overflow: hidden;
}
/* ── Section label — big watermark word ── */
.section-label {
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
color: var(--bdr); letter-spacing: 4px; text-transform: uppercase;
line-height: 1; margin-bottom: 48px; user-select: none;
opacity: 0.7;
}
.section-label .accent { color: rgba(56,189,248,0.15); }
/* ── Hero ── */
.hero { padding-top: 100px; }
.hero-brand {
display: flex; align-items: center; gap: clamp(16px, 3vw, 32px);
margin-bottom: 48px;
}
.hero-brand i {
font-size: clamp(60px, 12vw, 140px); color: var(--ac);
line-height: 1; display: block;
}
.hero-brand span {
font-family: var(--pixel); font-size: clamp(60px, 12vw, 140px);
color: var(--ac); letter-spacing: 4px; line-height: 1;
}
.hero-brand .typed { display: inline; }
.hero-brand .cursor {
display: inline-block; width: 0.55em; height: 1em;
background: var(--ac); vertical-align: text-bottom;
margin-left: 2px;
animation: cursor-blink 530ms steps(1) infinite;
}
.cursor-done { animation: cursor-blink 530ms steps(1) 3 forwards !important; }
@keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.hero-title {
font-size: clamp(48px, 8vw, 100px); font-weight: 700;
color: var(--hi); line-height: 1.05; margin-bottom: 32px; letter-spacing: -3px;
}
.hero-title .accent { color: var(--ac); }
.hero-title .dim { color: var(--bdr); }
.hero-sub { font-size: 16px; line-height: 1.8; max-width: 560px; margin-bottom: 48px; }
.hero-terminal {
background: var(--s1); border: 1px solid var(--bdr); border-radius: 8px;
padding: 20px 28px; font-size: 13px; line-height: 2.2; display: inline-block;
}
.hero-terminal .p { color: var(--gn); }
.hero-terminal .c { color: var(--hi); }
.hero-terminal .d { opacity: 0.3; }
/* ── Feature layout ── */
.feat {
display: grid; grid-template-columns: 1fr 1fr; gap: 80px;
width: 100%; align-items: center;
}
.feat.reverse .feat-text { order: 2; }
.feat.reverse .feat-visual { order: 1; }
.feat-text h3 {
font-size: 28px; color: var(--hi); font-weight: 700;
line-height: 1.3; margin-bottom: 20px; letter-spacing: -0.5px;
}
.feat-text p { font-size: 14px; line-height: 1.9; margin-bottom: 16px; }
.feat-text ul { list-style: none; margin-top: 16px; }
.feat-text li {
font-size: 13px; padding: 6px 0; display: flex; align-items: flex-start; gap: 10px; line-height: 1.6;
}
.feat-text li::before {
content: ''; width: 6px; height: 6px; border-radius: 50%;
background: var(--ac); flex-shrink: 0; margin-top: 8px;
}
.feat-visual {
background: var(--s1); border: 1px solid var(--bdr); border-radius: 10px; overflow: hidden;
}
/* ── Visual panels ── */
.vis-code {
padding: 28px 32px; font-size: 13px; line-height: 2; white-space: pre-wrap;
}
.vis-code .kw { color: var(--ac); }
.vis-code .str { color: var(--gn); }
.vis-code .tag { color: var(--yl); }
.vis-code .link { color: var(--ac2); }
.vis-code .cm { opacity: 0.3; }
.vis-code .hi { color: var(--hi); }
.vis-tags { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; align-content: flex-start; }
.vtag {
font-family: var(--pixel); font-size: 11px; padding: 8px 18px;
border-radius: 6px; border: 1px solid;
}
.vis-keys { padding: 32px; display: flex; flex-wrap: wrap; gap: 10px; }
.vkey {
font-family: var(--pixel); font-size: 12px; padding: 10px 16px;
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
color: var(--hi); min-width: 40px; text-align: center;
box-shadow: 0 3px 0 var(--bdr);
}
.vis-cards { padding: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.vcard {
background: var(--bg); border: 1px solid var(--bdr); border-radius: 6px;
padding: 16px; border-top: 3px solid;
}
.vcard-t { font-family: var(--pixel); font-size: 10px; color: var(--hi); margin-bottom: 8px; }
.vis-gitea { padding: 28px 32px; }
.vis-gitea .ghdr {
font-family: var(--pixel); font-size: 10px; color: var(--tx);
margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
}
.vis-gitea .issue-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
}
.vis-gitea .issue-row:last-child { border-bottom: none; }
.vis-gitea .issue-state { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.vis-gitea .issue-title { color: var(--hi); flex: 1; }
.vis-gitea .issue-labels { display: flex; gap: 4px; }
.vis-gitea .issue-label {
font-family: var(--pixel); font-size: 8px; padding: 2px 8px; border-radius: 3px;
}
.vis-gitea .issue-num { font-family: var(--pixel); font-size: 10px; color: var(--tx); }
.vis-relay { padding: 28px 32px; font-size: 12px; line-height: 2.2; }
.vis-relay .node {
display: inline-block; padding: 4px 14px; border-radius: 4px;
font-family: var(--pixel); font-size: 10px; border: 1px solid; margin: 2px;
}
.vis-relay .arrow { color: var(--bdr); margin: 0 6px; }
/* ── Screenshot ── */
.hero-screenshot {
margin-top: 56px; border-radius: 10px; overflow: hidden;
border: 1px solid var(--bdr);
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.03);
}
.hero-screenshot img { display: block; width: 100%; height: auto; }
/* ── Email visual ── */
.vis-inbox { padding: 24px 32px; }
.vis-inbox .inbox-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--bdr); font-size: 12px;
}
.vis-inbox .inbox-row:last-child { border-bottom: none; }
.vis-inbox .inbox-from { width: 120px; color: var(--hi); flex-shrink: 0; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vis-inbox .inbox-subject { flex: 1; color: var(--tx); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vis-inbox .inbox-badge {
font-family: var(--pixel); font-size: 8px; padding: 2px 8px;
border-radius: 3px; flex-shrink: 0;
}
.vis-inbox .inbox-row.spam { opacity: 0.4; }
.vis-inbox .inbox-row.spam .inbox-from { text-decoration: line-through; }
/* ── CTA ── */
.cta-section {
min-height: 60vh; display: flex; align-items: center; justify-content: center;
text-align: center; padding: 80px 48px;
}
.cta-section h2 {
font-family: var(--pixel); font-size: clamp(24px, 4vw, 48px);
color: var(--hi); margin-bottom: 16px; letter-spacing: -1px;
}
.cta-section p { font-size: 14px; margin-bottom: 40px; max-width: 440px; margin-left: auto; margin-right: auto; }
.cta-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
.cta-btn {
font-family: var(--pixel); font-size: 11px; padding: 12px 28px;
border-radius: 6px; transition: all 0.2s; text-decoration: none;
}
.cta-btn.primary { background: var(--ac); color: var(--bg); }
.cta-btn.primary:hover { background: #7dd3fc; }
.cta-btn.outline { border: 1px solid var(--bdr); color: var(--tx); }
.cta-btn.outline:hover { border-color: var(--ac); color: var(--ac); }
footer {
padding: 24px 48px; border-top: 1px solid var(--bdr);
font-family: var(--pixel); font-size: 9px; color: var(--tx); opacity: 0.3;
text-align: center;
}
@media (max-width: 900px) {
.section { padding: 60px 24px; }
.feat { grid-template-columns: 1fr; gap: 40px; }
.feat.reverse .feat-text, .feat.reverse .feat-visual { order: unset; }
.section-label { font-size: clamp(36px, 10vw, 80px); }
header { padding: 0 20px; }
header nav a:not(.gh-btn) { display: none; }
footer { padding: 24px; }
.cta-section { padding: 60px 24px; }
}
</style>
</head>
<body>
<header>
<div class="inner">
<span class="brand">hyprcrd</span>
<nav>
<a href="#search">search</a>
<a href="#tags">tags</a>
<a href="#editor">editor</a>
<a href="#gitea">gitea</a>
<a href="#relay">relay</a>
<a href="#email">email</a>
<a href="#deploy">deploy</a>
<a href="https://git.uesome.de/tanztee/hyprcrd" class="gh-btn">source</a>
</nav>
</div>
</header>
<main>
<section class="section hero">
<div class="hero-brand">
<i class="ph ph-terminal-window"></i>
<span><span class="typed" id="hero-typed"></span><span class="cursor" id="hero-cursor"></span></span>
</div>
<h1 class="hero-title">
stop losing<br>
<span class="accent">information</span><span class="dim">.</span>
</h1>
<p class="hero-sub">
Cards. Tags. Wiki-links. Markdown. Calendar. Encrypted sync between devices.
One SQLite file. Zero cloud. Built for finding things, not filing them.
</p>
<div class="hero-terminal">
<span class="p">$</span> <span class="c">git clone https://git.uesome.de/tanztee/hyprcrd</span><br>
<span class="p">$</span> <span class="c">cd hyprcrd && docker compose up -d</span><br>
<span class="d"># open localhost:8088 — set password — done</span>
</div>
<div class="hero-screenshot">
<img src="screenshot.png" alt="hyprcrd board view with cards, tags, and buckets">
</div>
</section>
<section class="section" id="search">
<div class="section-label">SEARCH</div>
<div class="feat">
<div class="feat-text">
<h3>Type to find. Instantly.</h3>
<p>Full-text search hits titles, card bodies, and tags at the same time. Combine <code>#tag</code> filters with free text — <code>#project deploy script</code> finds exactly what you think it does.</p>
<ul>
<li>Search + replace across every card in the database</li>
<li><code>#tag1 #tag2</code> for AND filtering</li>
<li>Scope to a specific bucket</li>
<li>Drag search results onto each other to group cards</li>
<li>Preview panel shows rendered markdown</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-code"><span class="cm">&gt;</span> <span class="hi">#project deploy</span>
<span class="cm"> 3 results in 2ms</span>
<span class="kw">API redesign</span>
<span class="cm">...the <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> script handles
staging and prod environments...</span>
<span class="kw">Infra checklist</span>
<span class="cm">...<mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark> to k8s cluster
after running integration tests...</span>
<span class="kw">Sprint retro</span>
<span class="cm">...discussed <mark style="background:rgba(56,189,248,0.15);color:var(--ac)">deploy</mark>
frequency and rollback strategy...</span></div>
</div>
</div>
</section>
<section class="section" id="tags">
<div class="section-label">TAGS</div>
<div class="feat reverse">
<div class="feat-text">
<h3>Write a hashtag.<br>It becomes structure.</h3>
<p>No separate tag dialog. No filing step. Write <code>#project</code> in the card body and it extracts automatically into a clickable, filterable tag.</p>
<ul>
<li>Hierarchical: <code>#project/frontend/auth</code> — each segment is independently clickable</li>
<li>Click to filter, Ctrl+click to combine (AND logic)</li>
<li>Tag quick-pick with <kbd>T</kbd> — searchable overlay of all existing tags</li>
<li>Assign via right-click context menu</li>
<li>Super-buckets: nest buckets into collapsible groups</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-tags">
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#project</span>
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/api</span>
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend</span>
<span class="vtag" style="background:rgba(167,139,250,0.06);color:var(--ac2);border-color:rgba(167,139,250,0.2);">#project/frontend/auth</span>
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#bug</span>
<span class="vtag" style="background:rgba(251,113,133,0.06);color:var(--rd);border-color:rgba(251,113,133,0.2);">#urgent</span>
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#ops</span>
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#done</span>
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#meeting</span>
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#termin</span>
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#research</span>
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#idea</span>
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#geburtstag</span>
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#personal</span>
</div>
</div>
</div>
</section>
<section class="section" id="editor">
<div class="section-label">EDITOR</div>
<div class="feat">
<div class="feat-text">
<h3>Markdown that does more.</h3>
<p>Every card is a markdown document with live preview. Enriched with tools that make it a knowledge system, not just a text file.</p>
<ul>
<li>Wiki-links: <code>[[Card Title]]</code> — auto-complete, backlinks, split view</li>
<li>Snippets: <code>?dat</code> <code>?table</code> <code>?code</code> <code>?todo</code></li>
<li>Inline calculator: <code>?3+5</code> space &rarr; 8</li>
<li>Line operations: Alt+Up/Down to move, Shift+Alt to duplicate</li>
<li>Upload images, audio, PDFs — paste or drag</li>
<li>Asset picker: thumbnail grid with search</li>
<li>Extract selection to new card with auto-link</li>
<li>Find &amp; replace within the card (Ctrl+H)</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-code"><span class="hi">## Sprint planning Q3</span>
<span class="cm">Goals for next quarter:</span>
- [x] Finish API redesign
- [x] Migrate auth to <span class="link">[[Architecture RFC]]</span>
- [ ] Load testing
- [ ] Deploy to prod
<span class="kw">**Deadline:**</span> 2026-07-01
<span class="kw">**Owner:**</span> @alice
See also: <span class="link">[[Infra checklist]]</span>
<span class="tag">#project/api</span> <span class="tag">#meeting</span></div>
</div>
</div>
</section>
<section class="section" id="gitea">
<div class="section-label"><span class="accent">GITEA</span></div>
<div class="feat reverse">
<div class="feat-text">
<h3>Cards become tickets.<br>Tickets stay linked.</h3>
<p>Connect any number of Gitea instances. Browse issues, create tickets directly from cards, and keep your project management in sync without leaving hyprcrd.</p>
<ul>
<li>Multi-account: connect unlimited Gitea servers with token auth</li>
<li>Default repo per account — pre-fills create form, stays editable</li>
<li>Right-click any card &rarr; <em>Make ticket</em>: title + body pre-fill the issue form</li>
<li>Full issue browser: filter by state, scope (assigned/created/mentioned)</li>
<li>Label picker, milestone selector, assignee multi-select</li>
<li>Repo autocomplete with fuzzy search across all repos</li>
<li>Inline issue detail: view body, labels, assignees, edit title, toggle state</li>
<li>Pagination for large repos</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-gitea">
<div class="ghdr">
<span style="color:var(--ac);">git.example.com</span>
<span style="opacity:0.3">/</span>
<span style="color:var(--hi);">team/hyprcrd</span>
<span style="margin-left:auto;padding:2px 10px;border-radius:3px;background:rgba(52,211,153,0.08);color:var(--gn);border:1px solid rgba(52,211,153,0.15);font-family:var(--pixel);">open</span>
</div>
<div class="issue-row">
<span class="issue-state" style="background:var(--gn);"></span>
<span class="issue-title">API redesign: migrate to v3 endpoints</span>
<span class="issue-labels">
<span class="issue-label" style="background:rgba(56,189,248,0.1);color:var(--ac);">enhancement</span>
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">api</span>
</span>
<span class="issue-num">#142</span>
</div>
<div class="issue-row">
<span class="issue-state" style="background:var(--gn);"></span>
<span class="issue-title">Auth timeout on mobile devices</span>
<span class="issue-labels">
<span class="issue-label" style="background:rgba(251,113,133,0.1);color:var(--rd);">bug</span>
<span class="issue-label" style="background:rgba(251,191,36,0.1);color:var(--yl);">priority</span>
</span>
<span class="issue-num">#139</span>
</div>
<div class="issue-row">
<span class="issue-state" style="background:var(--gn);"></span>
<span class="issue-title">Add CalDAV recurring event support</span>
<span class="issue-labels">
<span class="issue-label" style="background:rgba(52,211,153,0.1);color:var(--gn);">feature</span>
</span>
<span class="issue-num">#137</span>
</div>
<div class="issue-row">
<span class="issue-state" style="background:var(--ac2);"></span>
<span class="issue-title">Relay: per-pair key exchange for strict E2E</span>
<span class="issue-labels">
<span class="issue-label" style="background:rgba(244,114,182,0.1);color:var(--pk);">security</span>
<span class="issue-label" style="background:rgba(167,139,250,0.1);color:var(--ac2);">relay</span>
</span>
<span class="issue-num">#131</span>
</div>
<div class="issue-row">
<span class="issue-state" style="background:var(--tx);opacity:0.3;"></span>
<span class="issue-title" style="opacity:0.4;">Export: include plugin settings in backup</span>
<span class="issue-labels">
<span class="issue-label" style="background:rgba(56,189,248,0.05);color:var(--tx);opacity:0.5;">enhancement</span>
</span>
<span class="issue-num" style="opacity:0.4;">#128</span>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="relay">
<div class="section-label">RELAY</div>
<div class="feat">
<div class="feat-text">
<h3>Sync between devices.<br>Share with people.</h3>
<p>A self-hosted message relay that connects multiple hyprcrd instances. Per-user encryption. Collaborative buckets with exclusive card locking. Encrypted chat with read receipts.</p>
<ul>
<li>Multi-tenant: each user gets their own login + encryption secret</li>
<li>Cards sync between devices — push from one, poll from another</li>
<li>Collaborative buckets: shared editing with exclusive locks</li>
<li>Chat: encrypted messages with delivery + read receipts</li>
<li>Compose + inbox UI works on mobile</li>
<li>24h ephemeral — the relay routes, it doesn't archive</li>
<li>AES-256-CBC at every hop, bcrypt passwords, master key at rest</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-relay">
<div style="margin-bottom:20px;">
<span class="node" style="border-color:var(--ac);color:var(--ac);">alice</span>
<span class="arrow">&rarr;</span>
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">encrypts with own secret</span>
<span class="arrow">&rarr;</span>
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
</div>
<div style="margin-bottom:20px;padding-left:24px;border-left:2px solid var(--bdr);">
<span style="font-size:11px;color:var(--tx);">
1. authenticate sender<br>
2. decrypt with alice's secret<br>
3. re-encrypt with inter-tenant key<br>
4. store (24h TTL)<br>
5. on bob's poll: decrypt<br>
6. re-encrypt with bob's secret<br>
7. deliver
</span>
</div>
<div>
<span class="node" style="border-color:var(--yl);color:var(--yl);">relay</span>
<span class="arrow">&rarr;</span>
<span style="font-family:var(--pixel);font-size:10px;color:var(--tx);">re-encrypted for recipient</span>
<span class="arrow">&rarr;</span>
<span class="node" style="border-color:var(--gn);color:var(--gn);">bob</span>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="email">
<div class="section-label">EMAIL</div>
<div class="feat">
<div class="feat-text">
<h3>Built-in mail client.<br>Spam dies on arrival.</h3>
<p>A focused IMAP mail client built into hyprcrd. Not a replacement for your full email workflow — a triage tool. See what's real, nuke what's not, move on.</p>
<ul>
<li>Multi-account IMAP with encrypted credential storage</li>
<li>Automatic spam scoring — sender reputation, header analysis, pattern matching</li>
<li>One-click spam/ham training per sender — builds your personal allowlist/blocklist</li>
<li>Bulk select all flagged spam and delete in one action</li>
<li>Suspicious messages marked with warning badge and score breakdown</li>
<li>HTML rendering with external images blocked by default (tracking pixel protection)</li>
<li>Plain text fallback — always available, always safe</li>
<li>Pagination for large mailboxes, keyboard-friendly navigation</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-inbox">
<div class="inbox-row">
<span class="inbox-from">@alice</span>
<span class="inbox-subject">Re: Sprint planning — updated timeline attached</span>
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
</div>
<div class="inbox-row">
<span class="inbox-from">GitHub</span>
<span class="inbox-subject">[hyprcrd] PR #142 merged: API v3 migration</span>
<span class="inbox-badge" style="background:rgba(56,189,248,0.1);color:var(--ac);">notification</span>
</div>
<div class="inbox-row">
<span class="inbox-from">@carol</span>
<span class="inbox-subject">Relay docs draft — please review before Friday</span>
<span class="inbox-badge" style="background:rgba(52,211,153,0.1);color:var(--gn);">inbox</span>
</div>
<div class="inbox-row spam">
<span class="inbox-from">prince.ng@mail.ru</span>
<span class="inbox-subject">URGENT: $4.5M inheritance awaiting your claim</span>
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 94%</span>
</div>
<div class="inbox-row spam">
<span class="inbox-from">deals@sz-wholesale.cn</span>
<span class="inbox-subject">Limited offer!!! Best prices on electronics!!!</span>
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 87%</span>
</div>
<div class="inbox-row">
<span class="inbox-from">Sentry</span>
<span class="inbox-subject">New issue: TypeError in /api/relay/push</span>
<span class="inbox-badge" style="background:rgba(251,191,36,0.1);color:var(--yl);">? suspicious</span>
</div>
<div class="inbox-row spam">
<span class="inbox-from">crypto-gains@proton.me</span>
<span class="inbox-subject">Make 500% returns with this one trick</span>
<span class="inbox-badge" style="background:rgba(251,113,133,0.1);color:var(--rd);">spam 98%</span>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="keys">
<div class="section-label">KEYBOARD</div>
<div class="feat reverse">
<div class="feat-text">
<h3>40+ shortcuts.<br>Zero mouse required.</h3>
<p>Every action has a key binding. Arrow keys navigate the board. Number keys switch buckets. Quick capture creates cards without leaving the keyboard.</p>
<ul>
<li>Press <kbd>?</kbd> for the full shortcut reference</li>
<li>Shortcuts auto-suppress in input fields</li>
<li>Context-aware: editor shortcuts only fire in the editor</li>
<li>Styled tooltips and inline help across the UI</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-keys">
<span class="vkey">?</span>
<span class="vkey">N</span>
<span class="vkey">J</span>
<span class="vkey">T</span>
<span class="vkey">C</span>
<span class="vkey">W</span>
<span class="vkey">M</span>
<span class="vkey">/</span>
<span class="vkey">Esc</span>
<span class="vkey">F1</span>
<span class="vkey">Ctrl K</span>
<span class="vkey">Ctrl N</span>
<span class="vkey">Ctrl H</span>
<span class="vkey">Ctrl S</span>
<span class="vkey">Ctrl Z</span>
<span class="vkey">&uarr;</span>
<span class="vkey">&darr;</span>
<span class="vkey">&larr;</span>
<span class="vkey">&rarr;</span>
<span class="vkey">Enter</span>
<span class="vkey">Tab</span>
<span class="vkey">1</span>
<span class="vkey">2</span>
<span class="vkey">3</span>
<span class="vkey">...</span>
<span class="vkey">9</span>
</div>
</div>
</div>
</section>
<section class="section" id="deploy">
<div class="section-label">DEPLOY</div>
<div class="feat">
<div class="feat-text">
<h3>Docker. PHP host. Desktop app.</h3>
<p>Three deployment paths, same app. The Electron build bundles a static PHP binary — no PHP installation needed. Data lives outside the app bundle and survives updates.</p>
<ul>
<li>One SQLite file — back up by copying <code>data/hyprcrd.db</code></li>
<li>Export as JSON+media ZIP or Obsidian/Logseq markdown vault</li>
<li>Plugin system: drop a folder, reload, feature appears</li>
<li>Bundled: chat, email, Gitea, canvas, slideshows, Logseq import, watch</li>
<li>Fully themed: design tokens, presets, build your own</li>
<li>MIT licensed</li>
</ul>
</div>
<div class="feat-visual">
<div class="vis-cards">
<div class="vcard" style="border-color:var(--ac);">
<div class="vcard-t">Docker</div>
<div style="font-size:11px;color:var(--gn);line-height:1.8;">docker compose up -d<br><span style="opacity:0.3">&rarr; localhost:8088</span></div>
</div>
<div class="vcard" style="border-color:var(--ac2);">
<div class="vcard-t">PHP host</div>
<div style="font-size:11px;color:var(--tx);line-height:1.8;">Upload to webspace<br>Set docroot &rarr; public/</div>
</div>
<div class="vcard" style="border-color:var(--gn);">
<div class="vcard-t">Electron</div>
<div style="font-size:11px;color:var(--gn);line-height:1.8;">npm run dist<br><span style="opacity:0.3">&rarr; AppImage / DMG / NSIS</span></div>
</div>
<div class="vcard" style="border-color:var(--yl);">
<div class="vcard-t">Data</div>
<div style="font-size:11px;color:var(--tx);line-height:1.8;">data/hyprcrd.db<br>public/uploads/</div>
</div>
</div>
</div>
</div>
</section>
<section class="cta-section">
<div>
<h2>your brain, self-hosted.</h2>
<p>Set a password. Start writing. Find everything.</p>
<div class="cta-links">
<a href="https://git.uesome.de/tanztee/hyprcrd" class="cta-btn primary">get the source</a>
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/README.md" class="cta-btn outline">readme</a>
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/relay/README.md" class="cta-btn outline">relay docs</a>
<a href="https://git.uesome.de/tanztee/hyprcrd/src/branch/master/electron/README.md" class="cta-btn outline">electron</a>
</div>
</div>
</section>
</main>
<footer>hyprcrd &middot; MIT license &middot; v1.3.3</footer>
<script>
(function() {
const text = 'hyprcrd';
const el = document.getElementById('hero-typed');
const cursor = document.getElementById('hero-cursor');
if (!el) return;
let i = 0;
function type() {
if (i < text.length) {
el.textContent += text[i++];
setTimeout(type, 90 + Math.random() * 80);
} else {
setTimeout(() => cursor.classList.add('cursor-done'), 1500);
}
}
setTimeout(type, 400);
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Ad hoc server for the hyprcrd website
PORT=${1:-3000}
cd "$(dirname "$0")"
echo " hyprcrd website: http://localhost:$PORT"
python3 -m http.server "$PORT"