Compare commits

..
Author SHA1 Message Date
thandClaude Opus 4.6 4c3954d989 fix: compact todo-item spacing, fix checkbox alignment in detail
Todo items in preview were ~60px tall because marked.js wraps
checkbox-list content in <p> tags, and .prose p adds 0.75em
vertical margin inside the flex item. Three changes:

  - .todo-item p { margin:0; min-height:0 } kills the paragraph
    bloat inside todo rows.
  - Padding 5px→2px, min-height 28→24px, gap 10→8px for tighter
    stacking.
  - align-items switches from center to flex-start with margin-top
    on the checkbox so it sits at the first text line regardless of
    whether the <li> contains a bare text node or a <p>. Fixes the
    upward shift that appeared in the detail preview but not in the
    side panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:46:01 +02:00
thandClaude Opus 4.6 82b2ebc15f bookmarks: column-based bookmark manager plugin
New plugin at public/plugins/bookmarks/ — bookmark lists stored as
cards tagged #bookmarks with a JSON body. Board cards render as
coloured tiles (70% card colour + surface) with a centred link icon,
title, and date/bucket metadata; no body preview.

Clicking opens a full-screen overlay with:
  - Named columns (click header to rename, right-click for
    rename + delete), + button to add columns
  - Bookmark items: favicon (stored as data: URI via server-side
    fetch-meta) + title with ellipsis. Click opens URL in new tab.
  - Right-click item → rename or delete
  - Positional drag-and-drop between and within columns — accent
    drop-indicator line shows the insertion point
  - Full-width slider sets relative column width, persisted per card
  - Horizontal wheel-to-scroll on the column container

Adding a bookmark reads the clipboard; if it contains a URL, the
backend fetches <title> + favicon (as base64 data: URI, ≤32 KB) so
the card is fully self-contained. Non-URL clipboard → manual entry
dialog. Also accepts card:123 links — rendered with a card icon in
the linked card's colour; click opens the linked card's detail view
or, if it's another bookmark list, switches the overlay to it.

Multiple bookmark lists supported: header button always shows a
picker (even with one list) with a "new list" option. New lists
inherit the active bucket. Overlay title is click-to-rename.

Tests: plugin registration, header button injection, board card
decoration, overlay open/close + column + item rendering, fetch-meta
API shape, card-link icon rendering, column-width persistence.

Also fixes the flaky undo due_date test — replaced a fixed sleep
with waitForStateCard polling so the undo PATCH has time to settle.

Bumps to 1.9.23.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:33:18 +02:00
thandClaude Opus 4.6 883640e20c search: saved searches via bookmark button
Small bookmark icon in the search header bar opens a popup listing
localStorage-persisted search strings. Clicking an entry fills the
input and runs the search; the x button deletes it; "save current
search" at the bottom saves the active input (no dupes). List
persists across sessions, no server round-trip.

Tests: 6 cases covering button/popup toggle, save + recall, delete,
save-current-button, and duplicate rejection. All snapshot/restore
localStorage in try/finally so they don't leak into user data or
cross-test state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:09:18 +02:00
thandClaude Opus 4.6 be28080439 docs: website update plan as website/TODO.md
App audit (current 1.9.21 vs. site footer 1.3.3) found the site
~14 releases behind on content and missing whole feature areas.
Jotted the gap list and a prioritised plan so we can step through
it rather than redoing the survey each time. P0 (critical
freshness + Calendar/Relay rewrite + plugin details), P1 (gallery
+ workflows + real CTA targets), P2 (demo GIF, keyboard ref,
theming preview, changelog box, comparison table). "Not doing"
list at the bottom records the deliberate exclusions so we don't
re-litigate them later.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:31:03 +02:00
thandClaude Opus 4.6 c07340a3ec relay: consistent 16px gap between compose-sidebar sections on mobile
#compose-sidebar only had its flex-column + gap:16px declaration
inside the desktop media query, so on mobile the inbox/shared/sent
cards stacked as plain blocks with no deliberate spacing. Move the
rule to base CSS so the rhythm between inbox → shared → sent
matches the compose → inbox gap regardless of viewport width.
Desktop re-declares it next to the grid layout for clarity; the
duplication is harmless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:30:54 +02:00
thandClaude Opus 4.6 d052fb5f9d shopping: ship as plugin, integrate phone view into relay UI, fix sync
Restructure the shopping-list feature end to end:

  - New public/plugins/shopping/ plugin with backend.php (proxy
    routes registered via PluginManager::onApiRoute), shopping.js
    (self-contained; injects the header Shopping button, owns the
    picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
    placeholder css file. Core no longer knows about shopping —
    index.php loses the static button and the proxy block, app.js
    loses the initShopping wiring, the old js/modules/shopping.js
    is gone.
  - Phone view moves out of a standalone /shopping page and into
    the relay UI as a regular `view-shopping` behind a nav-burger
    entry (relay/ui/layout.php + app.js + style.css). Same session
    auth as compose/chat/account — no separate login. Auto-refresh
    every 4 s while the view is active; no manual refresh button.
    Headings "# h1 … ###### h6" render between items for grouping.
    Orphan section headings (no open items under them) are hidden.
  - Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
    timezones and silently blocked pushes after a pull). Both sides
    diff the (uid, title, body) triple instead — timezone-agnostic
    and authoritative. Client ticks push-before-pull so fresh local
    edits land on the relay before a stale pull can overwrite them.
    Relay drops its "server newer" guard and stamps server times
    with gmdate so anything left comparing to SQLite is UTC.
  - Client also ticks on cards.reloaded, not just sync.tick, so
    any edit path (editor save, checkbox toggle, context menu,
    undo) pushes the update immediately.
  - Tests: updated for the plugin (still uses the same selectors
    because the plugin injects the same ids), plus a plugin-
    registration check and "(no active list)" option assertion.
  - Help section copy updated — references the Shopping header
    button and the shopping tab in the relay UI rather than the
    gone /shopping URL.

Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:24:58 +02:00
thandClaude Opus 4.6 b9d610b612 shopping: UI strings all English
Picker dialog, hamburger menu entry, phone page and help section
still had German labels from the original request ("Einkaufsliste",
"keine aktive Liste", etc.). The rest of the app's UI is English,
so align everything here: menu entry is "Shopping list", dialog +
phone page copy translated, help references match. Trigger tags
stay as-is — both #einkauf and #shopping keep working so users with
either convention aren't disrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:48:05 +02:00
thandClaude Opus 4.6 d925070a36 fix(tests): shopping proxy-check test tolerates session-auth 401
/api/shopping/check is session-auth on the relay (phone-only), so
hitting it via the client proxy with client-auth credentials returns
{error: "unauthorized"} instead of a response with an ok field. The
test was over-specific — relax to either `ok:boolean` (relay not
configured, proxy's own fallback) or `error:string` (relay rejected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:43:14 +02:00
thandClaude Opus 4.6 e086db0c2c shopping: #shopping tag, help section, tests, client-side proxy
Three follow-ups:

  - Accept #shopping as an alias for #einkauf. Either tag now makes
    a card show up in the picker; the help blurb in the dialog says
    so explicitly.
  - Add a proper help-overlay section covering the trigger tags,
    how to pick an active list, the phone URL, sync cadence, and
    why phone is check-only.
  - Add a client-side proxy in public/index.php for /api/shopping
    and /api/shopping/check — shopping.js was calling api('api/...')
    which went to hyprcrd, but only the relay had handlers. Without
    this forwarder, push/pull just 404'd. Proxy mirrors the pattern
    used by /api/relay/push.
  - New shopping.test.js: asserts the proxy returns a JSON shape
    with `ok` (works whether or not a relay is configured), the
    hamburger menu toggles as expected, and the picker lists both
    #einkauf + #shopping cards while excluding untagged ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:35:52 +02:00
thandClaude Opus 4.6 670f125627 shopping list: phone-synced card via relay
Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.

  - Relay: shopping_state table (one row per tenant), POST/GET
    /api/shopping for content push/pull, POST /api/shopping/check
    for phone toggles, GET /shopping for the phone UI.
  - Client: hamburger menu in the header with an "Einkaufsliste"
    entry that opens a picker listing all #einkauf cards with a
    radio for the active one. Timestamp cursors in localStorage
    keep push/pull idempotent.
  - Phone can only toggle, not edit text — edit stays in hyprcrd,
    which makes the merge trivial (last-writer wins by timestamp).

Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:32:42 +02:00
thandClaude Opus 4.6 c2485b1307 fix(editor): appointment bucket-skip detects {id:null} shells too
The calendar day-popup invokes openModal with `{id: null, title, body}`
rather than a bare `null`, so `!card` was false and the #termin-body
exemption never fired — new appointments still inherited the active
bucket. Key the "is new" check on `card?.id` instead so both entry
shapes are treated the same.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:10:17 +02:00
thandClaude Opus 4.6 b08bdbaeb2 tests: assert new appointments don't inherit the active bucket
Opens the editor twice against a real bucket as activeBucket: once
with a #termin-seeded body (should stay empty in inp-bucket) and
once with a plain body (should pick up the active bucket id).
Skipped on fresh DBs that have no buckets to inherit from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:10 +02:00
thandClaude Opus 4.6 0e3088db9a editor: new appointments never inherit the active bucket
The modal's default-bucket logic dropped new cards into the current
real bucket when opened from one. Calendar-created appointments
(body pre-seeded with #termin / #geburtstag) would then land in e.g.
"work" instead of living bucket-less as calendar entries should.
Detect the pre-filled tag on open and force inpBucket empty for
those; regular new cards still inherit the active bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:04:39 +02:00
thandClaude Opus 4.6 29f92dbf9e search: right-click a hit to move it to a bucket
Drag was the only way to move a search hit into a bucket, which meant
opening the main board to find a drop target first. Right-click on a
search-hit now opens a small bucket picker anchored at the cursor:
clicking a bucket PATCHes the card's bucket_id and re-runs the
current search so the row updates in place. Includes an "(unsorted)"
entry that clears the bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:02:51 +02:00
thandClaude Opus 4.6 d5ebbab6a5 tests: assert journal entries don't get the local pulse
Covers the board.js journal exemption on the 1× card-pulse-local
flash. Creates two probe cards via the raw API, flags both as
"just created locally" in state.recentlyCreatedIds, runs a render,
then asserts that the journal-format one (title 9999-12-31 — a
year we can't collide with real entries) skipped the pulse class
while the __prefixed__ control card still got it.

Both probes are deleted in a finally block so a failed assertion
doesn't leave them behind for later suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:53:49 +02:00
thandClaude Opus 4.6 c9749352ec board: no new-card pulse for fresh journal entries
Closing a new journal entry briefly flashed the card with the
1× local-created pulse. Journal entries are daily-log noise, not
"look at this new thing" moments, so the flash reads as clutter.
Detect journals at pulse time (title starts YYYY-MM-DD, no
termin/geburtstag, no caldav_uid, no " — <summary>" suffix —
mirrors calendar.js isJournal) and drop them from the pulse set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:51:23 +02:00
thandClaude Opus 4.6 412dc527a3 help: let the dialog scroll
The shared modal-container rule slapped `overflow: hidden` on
`#help > div` to clip border-radius, which won over the Tailwind
`overflow-y-auto` on the same element and froze the scroll. Move
help to its own rule that keeps the rounded corners + shadow and
leaves overflow alone so the inline overflow-y-auto takes effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:06 +02:00
thandClaude Opus 4.6 27cc3f98ac tooltip: kill stuck tips with watchdog + pointermove sanity
Tooltips could strand on screen when pointerleave didn't fire — fast
flicks off the target, re-renders that yanked the element out mid-
hover, or the tab losing focus. Three guards:

  - 5s max-life watchdog on every show, so the worst case is a late
    fade rather than a permanent overlay
  - pointermove check: if the pointer is no longer over the tip's
    owning target (or the target got detached from the DOM), hide
  - window blur + visibilitychange also hide immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:43:12 +02:00
thandClaude Opus 4.6 64be9257f2 fix(tests): chat thread-settings test is idempotent
The row the test writes survives the backup/restore cycle (plugin
tables aren't in the export), so on the second run the GET returned
1 and the "starts at 0" assertion blew up. Explicitly reset the
flag at the start, then flip, then reset again — each run lands
the DB in the same state it started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:37:35 +02:00
thandClaude Opus 4.6 0022f64c50 chat: merge caret + title into one back trigger
Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:22 +02:00
thandClaude Opus 4.6 d2f7b98e6c chat: clicking the title goes back to the conversation list
Same path as the back-caret — a bigger, easier target than the
icon. No-op when already on the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:30:14 +02:00
thandClaude Opus 4.6 8020ddc787 tests: purge editor drafts after a run
Tests that opened the card modal left hyprcrd-editor-draft:* entries
in localStorage. The pre-run purge cleared them before the baseline
backup, but anything the tests wrote during the run stayed behind —
the user's next card open then fired "unsaved draft from 0 min ago,
restore?" for a non-edit they didn't make. Repeat the purge after
restore so the runner closes with a clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:28:29 +02:00
thandClaude Opus 4.6 87ce038b19 new-bucket dialog: Enter in name field submits
Typing a name and hitting Enter did nothing — users had to move to
the Create button. Wire Enter on the name input to click the submit
path so the common flow is keyboard-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:57 +02:00
thandClaude Opus 4.6 ccba2932c2 calendar: include Bis: in the appointment-create template
CalDavSync already reads **Bis:** out of the body on push (and writes
it on pull), but the in-app template used when the user clicks a
calendar day to create an appointment didn't offer the field —
forcing the user to type the marker from scratch for multi-day or
ranged events. Drop it in next to **Zeit:** so the round-trip is
symmetric.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:13 +02:00
thandClaude Opus 4.6 f2a33a9164 bucket-bar: hide scrollbar, soft edge fades, wheel-to-scroll
The horizontal scrollbar on the bucket row was ugly and
inconsistent with the rest of the app's scrollbar-less panels. Hide
it, redirect vertical wheel over the bar to horizontal scroll, and
surface overflow with soft bg-coloured gradients on whichever side
has more content off-screen: right only while anchored at the
start, both in the middle, left only at the end.

Fade overlays live on #bucket-row (parent) as pseudo-elements with
pointer-events:none, rather than mask-image on the bar itself — a
mask would also clip super-bucket popovers that are intentionally
rendered inside #bucket-bar for the collab MutationObserver.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:20:27 +02:00
thandClaude Opus 4.6 41272bd9ce board: spinner during first load instead of "no cards found"
An empty board on first load blurting "no cards found" was alarming
on a board the user knew had cards. Track a cardsLoaded flag that
flips on the first /api/cards (meta or full) response — or on a
cached-cards hydration — and show a centred spinner placeholder
while it's still false. The "no cards found" copy only shows once
we've actually heard back and the set is genuinely empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:16:45 +02:00
thandClaude Opus 4.6 d31e186bd9 docs: document app-only compose + fix dev.sh for relay profile
dev.sh still has to opt into the relay profile now that it's no
longer in the default set; otherwise `./dev.sh` would print "relay:
http://localhost:8081" but skip starting the container. README gets
a short compose section in Quick Start explaining the app-only
default and how to add --profile relay for a local relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:12:06 +02:00
thandClaude Opus 4.6 0ac0e34012 docker: make relay opt-in via compose profile
Running a local relay stopped being the common case — most of the
time we're developing against a real one. Move the relay service
behind a `relay` profile so plain `docker compose up` only brings up
the app. Use `docker compose --profile relay up` when a local relay
is wanted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:10:18 +02:00
thandClaude Opus 4.6 dce8fa4211 fix(tests): chat thread-settings test uses runner csrf helper
Was pulling CSRF from a meta tag that doesn't exist; the POST landed
without a valid token and the server dropped it before the plugin
handler could reply. Use the imported csrf() helper like the rest of
the suite does.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:04:02 +02:00
thandClaude Opus 4.6 8cd6015cad fix(chat): make group-chat author labels readable
Author name above group bubbles was 8px dim-grey pixel font — visible
in theory, invisible in practice. Bump to 10px semibold accent,
left-aligned above the incoming bubble it labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:00:03 +02:00
thandClaude Opus 4.6 6aa555cc6a fix(chat): group-message FK violation + receipt-cursor stall
Group inserts were using recipient_id=0 as a "no recipient" sentinel,
which fails the tenants FK (foreign_keys is ON) and 500s on every
group send. Use the sender's own id as the placeholder instead;
group GET queries already key off room_uid so there's no self-message
artefact from the value.

Also exclude group rows from /api/chat/sent so the client's
last_sent_relay_id cursor doesn't stall on group sends that never
reach seen=1 (group receipts aren't tracked in v1).

Client-side: surface a clearer error when a room create hits a 404
on the relay — almost always means the relay is running pre-group
code and needs a restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:57:18 +02:00
thandClaude Opus 4.6 0fd97e5d71 tests + version bump: group chat surface
Extends chat.test.js to cover the new conversation shape (kind/key
fields), the /api/chat/rooms endpoint, the thread-settings
round-trip, and the group-creation pick-view toggle. Room-info view
DOM is structurally checked. Bumps to 1.9.4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:55 +02:00
thandClaude Opus 4.6 9267c7c6a1 chat: group chat UI — room tabs, creation flow, member mgmt
Generalises the flyout from peer-only to a thread abstraction
({kind: 'peer'|'room', key, label}). Conversations list and peer
strip render rooms with a group icon alongside 1:1 peers; clicking
either opens the same thread view.

New UI surface:
  - Pick view gets a 1:1 / group mode switch. Group mode reveals a
    name field + multi-select member list and a Create button.
  - Room threads show an info icon in the flyout header that opens a
    member list; owners can add/remove members, rename-later, or
    delete the room; non-owners can leave.
  - 1:1 threads show a disposable toggle (ph-fire) in the header;
    flipping it tells the relay to drop future sends after delivery
    instead of persisting.
  - Group bubbles show the author's username above each message when
    it changes, matching common chat UX.

Thread open triggers a backfill fetch so cold caches / second
devices catch up on older history from the relay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:50 +02:00
thandClaude Opus 4.6 52da9abd44 chat: client-side rooms, disposable flag, history backfill
Extends the local chat cache to mirror rooms + membership from the
relay. chat_messages grows a room_uid column; peer is reused as
author-username for group rows so a single table covers both kinds.

New local tables:
  chat_rooms            — uid, name, owner, synced_at
  chat_room_members     — room_uid + username
  chat_thread_settings  — per-peer disposable flag

New / extended endpoints:
  GET/POST/PATCH/DELETE /api/chat/rooms[/{uid}] — proxy to relay
  POST /api/chat/backfill — pulls older messages from relay
  GET/POST /api/chat/thread-settings — disposable toggle
  /api/chat/conversations — mixed list of peers + rooms
  /api/chat/messages — accepts peer OR room_uid
  /api/chat/send — accepts peer OR room_uid; forwards disposable
  /api/chat/poll — mirrors rooms on every tick; sets direction
                   from `mine` flag so multi-device group sends
                   render as out-bubbles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:38 +02:00
thandClaude Opus 4.6 044caae447 relay: group chat rooms + persistent chat history
Adds named group threads (chat_rooms + chat_room_members), mirroring
the collab-buckets membership pattern. Group messages share the
chat_messages table via a new nullable room_uid column; recipient_id
is left 0 for group rows and fan-out happens via the member-join at
GET time instead of duplicating per recipient.

Chat retention flips from "drop everything after 7 days" to
persistent-by-default: a new `persistent` flag on chat_messages marks
long-term rows (default 1 — all group messages plus non-disposable
1:1). The 7-day cleanup only targets persistent=0 rows so the sender
can still poll final receipt state before they're pruned.

New endpoints:
  POST/GET/PATCH/DELETE /api/chat/rooms[/{uid}] — room CRUD
  GET  /api/chat/backfill — scrollback / cold-start history fetch
  POST /api/chat — accepts recipient OR room_uid + disposable flag
  GET  /api/chat — returns 1:1 to caller AND rooms caller is in

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:29 +02:00
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
115 changed files with 11283 additions and 762 deletions

No files matched your search

+4
View File
@@ -11,3 +11,7 @@
electron/node_modules electron/node_modules
data/hyprcrd.db-shm data/hyprcrd.db-shm
data/hyprcrd.db-wal data/hyprcrd.db-wal
/oumta-dashboard/
/oumta-dashboard-ui/
+71 -8
View File
@@ -23,6 +23,22 @@ Open `http://localhost:8080` and set your password on first visit.
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts. The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
### Via docker compose
If you prefer compose, the bundled `docker-compose.yml` starts the app alone by default:
```bash
docker compose up -d # just the app on :8088
```
To also bring up a local relay (for multi-tenant messaging / collab / chat), opt in via the `relay` profile:
```bash
docker compose --profile relay up -d # app on :8088 + relay on :8081
```
Most installs talk to a remote relay, so the app-only default keeps the local setup minimal.
## Manual Installation ## Manual Installation
### 1. Clone ### 1. Clone
@@ -110,10 +126,23 @@ hyprcrd/
│ │ └── vendor/ # marked.js, DOMPurify │ │ └── vendor/ # marked.js, DOMPurify
│ ├── plugins/ # Optional, drop-in plugin folders │ ├── plugins/ # Optional, drop-in plugin folders
│ │ ├── canvas/ # ASCII art canvas editor │ │ ├── canvas/ # ASCII art canvas editor
│ │ ├── chat/ # Encrypted relay chat
│ │ ├── collab-buckets/ # Collaborative bucket sync
│ │ ├── email/ # Email overlay
│ │ ├── gitea/ # Gitea issue integration
│ │ ├── logseq-import/ # Logseq markdown importer
│ │ ├── slideshow/ # Presentation mode
│ │ └── tests/ # UI + state test runner │ │ └── tests/ # UI + state test runner
│ └── uploads/ # User media (writable) │ └── uploads/ # User media (writable)
├── relay/ # Relay service (mobile inbox + card sharing) ├── electron/ # Electron desktop app wrapper
│ ├── index.php # Single-file API + Mobile UI │ ├── main.js # Spawns PHP, creates BrowserWindow
│ ├── router.php # PHP built-in server router
│ ├── fetch-php.sh # Downloads static PHP binaries
│ └── php-bin/ # Platform-specific PHP binaries
├── relay/ # Relay service (multi-tenant messaging + collab)
│ ├── index.php # Entry point + API dispatch
│ ├── inc/ # Backend modules (bootstrap, crypto, auth, routes)
│ ├── ui/ # Frontend (layout.php, style.css, app.js)
│ ├── .htaccess # URL rewriting + data protection │ ├── .htaccess # URL rewriting + data protection
│ ├── Dockerfile │ ├── Dockerfile
│ └── dist.sh # Build relay distribution ZIP │ └── dist.sh # Build relay distribution ZIP
@@ -128,7 +157,7 @@ hyprcrd/
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically - **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin) - **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
- **Search** combining text and `#tag` filters (e.g. `#project meeting notes`) - **Search** combining text and `#tag` filters (e.g. `#project meeting notes`)
- **Image gallery** with orphan detection and fullscreen viewer - **Assets gallery** (images, audio, PDFs) with orphan detection and fullscreen viewer
- **Split view** showing tag-related and content-related cards - **Split view** showing tag-related and content-related cards
- **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault - **Import/Export** as JSON+media ZIP or Obsidian/Logseq-compatible markdown vault
- **Drag & drop** card reordering and grouping - **Drag & drop** card reordering and grouping
@@ -141,7 +170,14 @@ hyprcrd/
- **Notification banner** for upcoming events, birthdays, overdue tasks - **Notification banner** for upcoming events, birthdays, overdue tasks
- **Board split view** with side-by-side preview - **Board split view** with side-by-side preview
- **Superview** compact multi-column list with hover cards - **Superview** compact multi-column list with hover cards
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete - **Super-buckets** — nest buckets one level deep with a dropdown for children
- **Card groups** — drag-to-group, join multiple cards, dissolve
- **Context menu** — color, tags, copy text, mark done, join cards, make ticket (gitea)
- **Find & replace** — in-card (Ctrl+H) and across all cards (Ctrl+Shift+H via search)
- **Arrow key navigation** on the board with Enter to open
- **PDF upload** in the editor toolbar
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `?` shortcut reference, arrow keys, 1–9 bucket switch
- **Electron desktop app** — standalone AppImage/DMG/NSIS with bundled PHP, no server needed
## Plugins ## Plugins
@@ -150,7 +186,13 @@ hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plu
### Bundled plugins ### Bundled plugins
- **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid. - **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid.
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `?test` is in the URL or `Ctrl+Shift+T` is pressed, so production users pay no cost for it. - **`chat/`** — Encrypted chat between relay tenants. Footer flyout with delivery + read receipts.
- **`collab-buckets/`** — Collaborative buckets shared between relay tenants with exclusive card locking and E2E encrypted sync.
- **`email/`** — Email overlay (toggled with `M` key).
- **`gitea/`** — Gitea integration: create issues from cards, link to repos.
- **`slideshow/`** — Slideshow presentation mode for cards with `slide-image` blocks.
- **`logseq-import/`** — Import Logseq markdown journals and pages.
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `Ctrl+Shift+Alt+T` is pressed, so production users pay no cost for it.
### Plugin layout ### Plugin layout
@@ -392,6 +434,8 @@ access via `.htaccess`).
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`. Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
Under the hood this runs `docker compose --profile relay up --build`. The relay service lives behind the `relay` compose profile, so a plain `docker compose up` brings up only the app — handy when you're developing against a remote relay.
### Setup workflow ### Setup workflow
1. Open the relay URL **from a desktop browser** — the setup wizard 1. Open the relay URL **from a desktop browser** — the setup wizard
@@ -417,9 +461,8 @@ Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:808
master key lives outside the DB in a chmod-600 file master key lives outside the DB in a chmod-600 file
- **24-hour ephemeral**: messages and attachments auto-delete after a - **24-hour ephemeral**: messages and attachments auto-delete after a
day. Cards extracted in hyprcrd are the long-term store day. Cards extracted in hyprcrd are the long-term store
- **Future: end-to-end encrypted chat** between tenants is planned and - **Encrypted chat**: tenant-to-tenant chat with delivery + read receipts
will require per-pair key exchange — see `relay/README.md` for the (via chat plugin). Per-pair E2E key exchange is on the roadmap
roadmap
### Relay Security ### Relay Security
@@ -451,6 +494,26 @@ your next visit. You'll need to:
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
client secrets need to be reissued. client secrets need to be reissued.
## Electron Desktop App
hyprcrd can run as a standalone desktop app via Electron — no web server needed.
```bash
cd electron
./fetch-php.sh # download static PHP binary (~20MB)
npm install
npm start # dev mode
npm run dist # build AppImage / DMG / NSIS
```
The app bundles a static PHP binary per platform and stores user data (database + uploads) outside the app bundle:
- **Linux**: `~/.config/hyprcrd/data/`
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
- **Windows**: `%APPDATA%/hyprcrd/data/`
See [`electron/README.md`](electron/README.md) for details.
## Rebuilding Tailwind CSS ## Rebuilding Tailwind CSS
After changing Tailwind utility classes in HTML or JS files: After changing Tailwind utility classes in HTML or JS files:
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 <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.1.0'); define('APP_VERSION', '1.9.24');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"disabled": [ "disabled": [
"logseq-import" "logseq-import",
"example-content"
] ]
} }
+6 -2
View File
@@ -1,9 +1,11 @@
#!/bin/bash #!/bin/bash
# Start hyprcrd + relay for local development # Start hyprcrd + relay for local development. The relay service is
# behind a compose profile since it's opt-in — this script explicitly
# opts in so you get both containers.
cd "$(dirname "$0")" cd "$(dirname "$0")"
docker compose up -d --build docker compose --profile relay up -d --build
echo "" echo ""
echo " hyprcrd: http://localhost:8088" echo " hyprcrd: http://localhost:8088"
@@ -11,3 +13,5 @@ echo " relay: http://localhost:8081"
echo "" echo ""
echo " relay from hyprcrd container: http://relay:80" echo " relay from hyprcrd container: http://relay:80"
echo "" echo ""
echo " (for app-only, drop the relay profile: docker compose up -d)"
echo ""
+7 -21
View File
@@ -1,35 +1,21 @@
#!/bin/bash #!/bin/bash
# Build hyprcrd distribution ZIP with version bump + minification # Build hyprcrd distribution ZIP (no version bump — use ./bump.sh first)
set -e set -e
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
echo "Usage: ./dist.sh [--patch|--minor|--major]" echo "Usage: ./dist.sh"
echo "" echo ""
echo " --patch bump patch version (default) 0.1.1 → 0.1.2" echo "Builds Tailwind CSS, minifies JS/CSS, and creates"
echo " --minor bump minor version 0.1.2 → 0.2.0" echo "hyprcrd-{version}.zip ready for deployment."
echo " --major bump major version 0.2.0 → 1.0.0"
echo "" echo ""
echo "Builds Tailwind CSS, minifies JS/CSS, bumps version in config.php," echo "To bump the version first: ./bump.sh [--patch|--minor|--major]"
echo "and creates hyprcrd-{version}.zip ready for deployment."
exit 0 exit 0
fi fi
cd "$(dirname "$0")" cd "$(dirname "$0")"
# Read current version VERSION=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'")
CURRENT=$(grep "APP_VERSION" config.php | grep -oP "'[^']+'" | tail -1 | tr -d "'") echo "Building v${VERSION}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
# Bump: --major, --minor, or patch (default)
case "${1:-patch}" in
--major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
--minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
*) PATCH=$((PATCH + 1)) ;;
esac
VERSION="${MAJOR}.${MINOR}.${PATCH}"
sed -i "s/APP_VERSION', '${CURRENT}'/APP_VERSION', '${VERSION}'/" config.php
echo "Version: ${CURRENT} → ${VERSION}"
DIST="hyprcrd-${VERSION}.zip" DIST="hyprcrd-${VERSION}.zip"
STAGE="/tmp/hyprcrd-stage-$$" STAGE="/tmp/hyprcrd-stage-$$"
+4
View File
@@ -9,8 +9,12 @@ services:
- ./public:/var/www/html/public - ./public:/var/www/html/public
- ./config.php:/var/www/html/config.php - ./config.php:/var/www/html/config.php
# Relay is opt-in via the `relay` profile. Plain `docker compose up`
# starts just the app; use `docker compose --profile relay up` when
# you want to develop against a local relay instead of a remote one.
relay: relay:
build: ./relay build: ./relay
profiles: ["relay"]
ports: ports:
- "8081:80" - "8081:80"
volumes: volumes:
+7 -2
View File
@@ -10,8 +10,13 @@ per platform so users don't need PHP installed.
3. A `BrowserWindow` loads `http://127.0.0.1:<port>/` 3. A `BrowserWindow` loads `http://127.0.0.1:<port>/`
4. On quit, the PHP process is killed cleanly 4. On quit, the PHP process is killed cleanly
Data is stored in `data/hyprcrd.db` (SQLite), relative to the project root User data (database + uploads) is stored **outside** the app bundle so it
(dev) or inside the app's `resources/` directory (packaged). survives updates:
- **Dev**: `electron/appdata/`
- **Linux**: `~/.config/hyprcrd/data/`
- **macOS**: `~/Library/Application Support/hyprcrd/data/`
- **Windows**: `%APPDATA%/hyprcrd/data/`
## Development ## Development
+3
View File
@@ -118,6 +118,9 @@ async function startPhpServer() {
console.log('[hyprcrd] port:', serverPort); console.log('[hyprcrd] port:', serverPort);
phpProcess = spawn(phpBin, [ phpProcess = spawn(phpBin, [
'-d', 'upload_max_filesize=64M',
'-d', 'post_max_size=66M',
'-d', 'display_errors=0',
'-S', `127.0.0.1:${serverPort}`, '-S', `127.0.0.1:${serverPort}`,
'-t', docRoot, '-t', docRoot,
routerFile, routerFile,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.1.0", "version": "1.9.24",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+9 -9
View File
@@ -6,17 +6,17 @@ installation.
## Download ## Download
Get static builds from https://github.com/crazywhalecc/static-php-cli/releases Get static builds from https://dl.static-php.dev/static-php-cli/common/
(look for `php-8.3-*-cli` assets). Required extensions: pdo_sqlite, zip, session. (look for `php-8.3.*-cli-*` files). Required extensions: pdo_sqlite, zip, session.
linux/php — x86_64 Linux ELF binary linux/php — x86_64 Linux ELF binary
darwin/php — macOS universal binary (arm64 + x86_64) darwin/php — macOS aarch64 binary
win32/php.exe — Windows PE binary win32/php.exe — Windows PE binary (not available from static-php-cli; use windows.php.net)
## Quick fetch (Linux example) ## Quick fetch
curl -L -o linux/php \ Use the included script from the `electron/` directory:
"https://dl.static-php.dev/static-php-cli/common/php-8.3.23-cli-linux-x86_64.tar.gz" \
&& tar -xzf linux/php -C linux/ && rm linux/php.tar.gz
Make sure the binary is executable: `chmod +x linux/php` ./fetch-php.sh
This auto-detects the latest PHP 8.3 version and downloads Linux + macOS binaries.
+222 -24
View File
@@ -1,7 +1,34 @@
body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); } body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
/* Tooltip system */
#tooltip {
display: none;
position: fixed;
z-index: 9999;
max-width: 280px;
padding: 4px 8px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
line-height: 1.5;
color: var(--t-bright);
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
pointer-events: none;
white-space: nowrap;
}
#tooltip.tooltip-help {
white-space: normal;
padding: 8px 12px;
font-size: 10px;
max-width: 320px;
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
color: var(--t-muted);
border-left: 3px solid var(--t-accent);
}
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; } .font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
.brand { font-family: 'Silkscreen', cursive !important; } .brand { font-family: 'Silkscreen', cursive !important; }
body.no-scanlines::after { display: none !important; }
#dialog-msg { white-space: pre-line; word-break: break-word; } #dialog-msg { white-space: pre-line; word-break: break-word; }
/* ── User-customizable design tokens ── */ /* ── User-customizable design tokens ── */
@@ -28,7 +55,7 @@ body.no-scanlines::after { display: none !important; }
#main-header { box-shadow: var(--ui-shadow-header); } #main-header { box-shadow: var(--ui-shadow-header); }
#main-footer { box-shadow: var(--ui-shadow-footer); position: relative; } #main-footer { box-shadow: var(--ui-shadow-footer); position: relative; }
#footer-count { flex-shrink: 0; } #footer-count { flex-shrink: 0; }
@media (min-width: 1250px) { @media (min-width: 1450px) {
#footer-count { position: absolute; left: 50%; translate: -50% 0; } #footer-count { position: absolute; left: 50%; translate: -50% 0; }
} }
html { min-width: 1150px; } html { min-width: 1150px; }
@@ -86,12 +113,22 @@ select[class*="border-t-accent"] {
#modal > div, #modal > div,
#detail > div, #detail > div,
#settings-overlay > div, #settings-overlay > div,
#help > div,
#search > div, #search > div,
#tasks > div, #tasks > div,
#calendar > div, #calendar > div,
#gallery > div, #gallery > div,
#dialog > div { #dialog > div,
#detail > div,
#modal > div {
border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal);
overflow: hidden;
}
/* The help dialog scrolls its own body — keep the border-radius and
shadow but drop overflow:hidden so the Tailwind overflow-y-auto on
the same element actually takes effect. */
#help > div {
border-radius: var(--ui-radius-modal); border-radius: var(--ui-radius-modal);
box-shadow: var(--ui-shadow-modal); box-shadow: var(--ui-shadow-modal);
} }
@@ -141,7 +178,6 @@ body.anim-slow .card-spinner {
position: relative; padding: 14px; overflow: hidden; position: relative; padding: 14px; overflow: hidden;
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px; background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
} }
.theme-preview .tp-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px); }
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; } .theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
.theme-preview .tp-card { .theme-preview .tp-card {
flex: 1; min-width: 0; flex: 1; min-width: 0;
@@ -196,7 +232,7 @@ body.anim-slow .card-spinner {
.design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; } .design-row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
.design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); } .design-row > label { flex: 0 0 88px; font-size: 11px; color: var(--t-muted); }
.design-row > select { width: 110px; } .design-row > select { width: 110px; }
.design-row > .design-val { font-family: var(--t-font-pixel); font-size: 9px; color: var(--t-dim); width: 36px; text-align: right; flex-shrink: 0; } .design-row > .design-val { font-size: 11px; color: var(--t-muted); width: 44px; text-align: right; flex-shrink: 0; }
.design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; } .design-section-label { font-family: var(--t-font-pixel); font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--t-dim); margin: 12px 0 4px; }
.design-section-label:first-child { margin-top: 0; } .design-section-label:first-child { margin-top: 0; }
#design-advanced > summary { list-style: none; } #design-advanced > summary { list-style: none; }
@@ -268,16 +304,6 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); } *::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
*::-webkit-scrollbar-corner { background: transparent; } *::-webkit-scrollbar-corner { background: transparent; }
/* Scanlines */
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px);
}
/* Blinking cursor */ /* Blinking cursor */
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; } .cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
@@ -463,9 +489,9 @@ body::after {
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } .card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; } .card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
.card-focused { .card-focused {
outline: 2px solid var(--t-accent2) !important; outline: 2px solid var(--t-accent) !important;
outline-offset: -2px; outline-offset: -2px;
box-shadow: 0 0 0 1px var(--t-accent2), 0 0 16px -4px color-mix(in srgb, var(--t-accent2) 40%, transparent) !important; box-shadow: 0 0 0 1px var(--t-accent), 0 0 16px -4px color-mix(in srgb, var(--t-accent) 40%, transparent) !important;
} }
.card-select-check { .card-select-check {
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px; position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
@@ -761,6 +787,50 @@ body.detail-image-fs-open { overflow: hidden; }
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important; background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
} }
/* Bucket bar: no visible scrollbar, soft edge fades when the row
overflows so the user knows there's more off-screen. Fade is
implemented via absolutely-positioned overlay pseudo-elements on
#bucket-row (parent) rather than mask-image on the bar itself,
because mask-image would also clip super-bucket popovers rendered
inside the bar. Overlays use pointer-events:none so clicks pass
through to the tabs underneath. */
#bucket-bar {
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
}
#bucket-bar::-webkit-scrollbar { display: none; }
#bucket-row::before,
#bucket-row::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 2;
}
#bucket-row::before {
left: 24px; /* matches .px-6 = 24px on #bucket-row */
background: linear-gradient(to right, var(--t-bg) 0%, transparent 100%);
}
#bucket-row::after {
/* Stop at the right edge of #bucket-bar (before the toolbar
toggles). The toolbar is flex-0, bar is flex-1 — the easiest
way is to inset the overlay by the total width of the toolbar
via a right offset that the JS writes. Defaults to 24px (the
.px-6 pad) and the JS overrides with the real value. */
right: var(--bucket-row-right-inset, 24px);
background: linear-gradient(to left, var(--t-bg) 0%, transparent 100%);
}
#bucket-row.fade-left::before,
#bucket-row.fade-both::before { opacity: 1; }
#bucket-row.fade-right::after,
#bucket-row.fade-both::after { opacity: 1; }
/* Unread dot on the fixed `all` bucket-tab — appears when new cards /* Unread dot on the fixed `all` bucket-tab — appears when new cards
arrive while the user is viewing a different bucket. Cleared the arrive while the user is viewing a different bucket. Cleared the
moment the user navigates back to all (renderBoard drains the moment the user navigates back to all (renderBoard drains the
@@ -821,6 +891,24 @@ body.anim-slow .board-card.card-pulse-local {
/* Super-bucket dropdown popover. Positioned via inline styles by /* Super-bucket dropdown popover. Positioned via inline styles by
openSuperDropdown() in buckets.js. Lives on document.body (not openSuperDropdown() in buckets.js. Lives on document.body (not
inside #bucket-bar) so it can escape the bar's overflow:auto. */ inside #bucket-bar) so it can escape the bar's overflow:auto. */
.super-caret {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 2px;
padding: 2px 4px;
border-radius: 3px;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
cursor: pointer;
}
.super-caret:hover {
opacity: 1;
background: color-mix(in srgb, var(--t-accent) 25%, transparent);
}
.bucket-tab.active .super-caret:hover {
background: rgba(0,0,0,0.25);
}
#bucket-super-popover { #bucket-super-popover {
z-index: 220; z-index: 220;
background: var(--t-surface); background: var(--t-surface);
@@ -895,7 +983,7 @@ body.anim-slow .board-card.card-pulse-local {
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); } #tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
/* Toolbar */ /* Toolbar */
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; } .toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: var(--ui-radius-button); transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); } .toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
/* Editor */ /* Editor */
@@ -1030,17 +1118,18 @@ body.anim-slow .board-card.card-pulse-local {
margin-left: -1.5em !important; margin-left: -1.5em !important;
display: flex !important; display: flex !important;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 8px;
min-height: 28px; min-height: 24px;
padding: 5px 0; padding: 2px 0;
} }
.todo-item p { margin: 0 !important; min-height: 0 !important; }
.todo-cb { margin-top: 2px; }
.todo-item { cursor: pointer; } .todo-item { cursor: pointer; }
.todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); } .todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); }
.todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; } .todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; }
.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; } .todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; }
.todo-cb { .todo-cb {
width: 18px; height: 18px; min-width: 18px; width: 18px; height: 18px; min-width: 18px;
transform: translateY(-9px);
border: 2px solid var(--t-border); border-radius: 3px; border: 2px solid var(--t-border); border-radius: 3px;
background: transparent; cursor: pointer; background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -1093,9 +1182,13 @@ body.anim-slow .board-card.card-pulse-local {
#board-preview-body.prose img, #board-preview-body.prose img,
#board-preview-body .prose img { #board-preview-body .prose img {
max-width: 100%; max-width: 100%;
width: auto;
height: auto; height: auto;
} }
#editor-preview .prose img:not([width]),
#board-preview-body.prose img:not([width]),
#board-preview-body .prose img:not([width]) {
width: auto;
}
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; } .prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; } .prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; } .prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
@@ -1154,3 +1247,108 @@ body.anim-slow .board-card.card-pulse-local {
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before { #detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
border-color: var(--t-accent); border-color: var(--t-accent);
} }
/* Remote-edit banner in the detail view.
Shown when a foreign sync replaced the card we're currently reading — the
user gets a manual Reload button instead of having the content change
under them mid-read. */
.detail-stale-banner {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px;
background: color-mix(in srgb, var(--t-accent2) 10%, transparent);
border-bottom: 1px solid var(--t-accent2);
font-size: 11px;
color: var(--t-muted);
}
.detail-stale-banner i.ph-info { color: var(--t-accent2); flex-shrink: 0; font-size: 14px; }
.detail-stale-banner span { flex: 1; }
.detail-stale-banner button {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
padding: 3px 8px;
background: transparent;
border: 1px solid var(--t-accent2);
color: var(--t-accent2);
cursor: pointer;
border-radius: var(--ui-radius-button);
transition: all 0.15s;
}
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
/* Sync + autosync combo: the pair reads as one button.
- At rest: Sync has no right border, autosync has no left border —
only the outer rectangle remains.
- Hover Sync: the right border comes back in accent so Sync gets a
complete outline around itself.
- Hover autosync: no accent border at all — instead a background
fill so the pair doesn't end up with a mismatched half-outline. */
.header-sync-group { align-items: stretch; }
.header-sync-group > button:first-child {
border-right: 0 !important;
}
.header-sync-group > button:first-child:hover {
border-right: 1px solid var(--t-accent) !important;
}
.header-sync-group > button + button {
border-left: 0 !important;
align-self: stretch;
}
#header-autosync:hover {
background: var(--t-accent);
border-color: var(--t-accent) !important;
color: var(--t-bg) !important;
}
/* Autosync toggle button.
Border stays neutral in both states — the dot carries the status
signal (accent + pulse when on, grey when off). Hover styling is
handled by the bg-fill rule further down. */
#header-autosync {
border-color: var(--t-border);
color: var(--t-muted);
}
.header-autosync-dot {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--t-accent);
transition: background 0.15s, transform 0.2s;
}
#header-autosync[data-autosync="off"] .header-autosync-dot {
background: var(--t-dim);
box-shadow: none;
}
/* Hover: button fills with solid accent (matching the Sync button's
hover border color). Dot switches to the app background color so it
reads as a knockout against the fill. Pulsing keeps tracking real
status — when a sync fires during hover the ring still animates. */
#header-autosync:hover .header-autosync-dot {
background: var(--t-bg);
}
/* Pulse shadow is accent-colored too, which would be invisible against
the accent fill. Swap it to a bg-toned ring during hover so the
pulse stays visible. */
#header-autosync:hover .header-autosync-dot.pulsing {
animation: header-autosync-pulse-on-hover 0.9s ease-out 1;
}
@keyframes header-autosync-pulse-on-hover {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-bg) 75%, transparent); transform: scale(1); }
40% { transform: scale(1.4); }
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-bg) 0%, transparent); transform: scale(1); }
}
/* Burst pulse on each sync. JS adds `.pulsing` at the start of a sync
and removes it after the animation finishes. Tuned to be visible
against the button background — a generous ring that fades out. */
.header-autosync-dot.pulsing {
animation: header-autosync-pulse 0.9s ease-out 1;
}
@keyframes header-autosync-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-accent) 75%, transparent); transform: scale(1); }
40% { transform: scale(1.4); }
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-accent) 0%, transparent); transform: scale(1); }
}
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolderLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
+201 -64
View File
@@ -17,10 +17,20 @@ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
} }
// ── Session ── // ── Session ──
//
// 30-day persistence: single-user local-first app, no reason to log the
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
// server-side GC are set to the same duration so the session survives
// reboots AND page reloads, up to a month of inactivity.
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
session_set_cookie_params([ session_set_cookie_params([
'lifetime' => $sessionLifetime,
'path' => '/',
'httponly' => true, 'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'samesite' => 'Strict', 'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
]); ]);
session_start(); session_start();
@@ -250,12 +260,14 @@ if (str_starts_with($route, '/api/')) {
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
$pos = $input['position'] ?? 0; $pos = $input['position'] ?? 0;
$body = $input['body'] ?? ''; $body = $input['body'] ?? '';
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->execute([ $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null), 'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null),
'parent_id' => validInt($input['parent_id'] ?? null, null), 'parent_id' => validInt($input['parent_id'] ?? null, null),
'position' => validInt($pos), 'position' => validInt($pos),
'tags' => extractTags($body), 'tags' => extractTags($body),
'due_date' => $input['due_date'] ?? null,
'scratchpad' => $input['scratchpad'] ?? null,
]); ]);
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch()); echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
exit; exit;
@@ -293,10 +305,32 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit; echo json_encode(['ok' => true]); exit;
} }
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
$stmt->execute(['id' => $m[1]]);
echo json_encode($stmt->fetchAll()); exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') { if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
// Snapshot changed fields before update
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
$changedTracked = array_intersect($tracked, array_keys($input));
if ($changedTracked) {
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
if ($old) {
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
foreach ($changedTracked as $f) {
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
if ((string)$oldVal !== (string)$newVal) {
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
}
}
}
}
$fields = []; $params = ['id' => $m[1]]; $fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) { foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
} }
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
@@ -868,6 +902,32 @@ if (str_starts_with($route, '/api/')) {
} }
$pulled++; $pulled++;
// Fire a sync-event notification to the sender so their hyprcrd
// surfaces a toast. Best-effort — an older relay may not have
// /api/sync-events, and we don't want to fail the whole poll.
$senderName = $msg['sender_username'] ?? '';
if ($senderName && $senderName !== $relayUser) {
$eventBody = $relayUser . ' synced: ' . $title;
$bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($bch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $relayUser,
'X-Relay-Key: ' . $relayKey,
],
CURLOPT_TIMEOUT => 5,
]);
$bRes = curl_exec($bch);
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
curl_close($bch);
@error_log('[hyprcrd] sync-event POST → ' . $senderName
. ' status=' . $bStat
. ' body=' . substr((string)$bRes, 0, 120));
}
// Download attachments // Download attachments
if ($relayUid) { if ($relayUid) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid)); $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
@@ -900,14 +960,45 @@ if (str_starts_with($route, '/api/')) {
} }
if (strlen($raw) >= 17) { if (strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); $decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($decrypted !== false) file_put_contents($dest, $decrypted); if ($decrypted !== false) {
$fileBytes = base64_decode($decrypted);
if ($fileBytes !== false) $decrypted = $fileBytes;
file_put_contents($dest, $decrypted);
}
} }
} }
} }
} }
} }
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit; // Drain pending sync-event notifications so the JS caller can toast
// them. GET /api/sync-events on the relay auto-marks fetched events.
// Best-effort — older relays won't have the endpoint.
$syncEvents = [];
$syncDebug = ['requested' => true];
$sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events');
curl_setopt_array($sch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 5,
]);
$sResp = curl_exec($sch);
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
curl_close($sch);
$syncDebug['status'] = $sStatus;
if ($sStatus === 200) {
$decoded = json_decode($sResp, true);
if (is_array($decoded) && isset($decoded['events'])) {
$syncEvents = $decoded['events'];
$syncDebug['count'] = count($syncEvents);
} else {
$syncDebug['parse_error'] = substr((string)$sResp, 0, 200);
}
} else {
$syncDebug['body'] = substr((string)$sResp, 0, 200);
}
echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents, 'sync_debug' => $syncDebug]); exit;
} }
// List relay tenants (for the recipient picker) // List relay tenants (for the recipient picker)
@@ -1130,7 +1221,6 @@ if (str_starts_with($route, '/api/')) {
// color theme // color theme
theme: 'slate', theme: 'slate',
// typography // typography
scanlines: '1',
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel', fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
// geometry // geometry
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0, radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
@@ -1144,7 +1234,6 @@ if (str_starts_with($route, '/api/')) {
window.builtinPresets = { window.builtinPresets = {
Terminal: { ...window.designDefaults }, Terminal: { ...window.designDefaults },
Modern: { theme: 'paper', Modern: { theme: 'paper',
scanlines: '0',
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono', fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8, radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
borderCard: 1, borderStyle: 'solid', barHeight: 3, borderCard: 1, borderStyle: 'solid', barHeight: 3,
@@ -1182,7 +1271,6 @@ if (str_starts_with($route, '/api/')) {
root.setProperty('--ui-lift-card', d.liftCard + 'px'); root.setProperty('--ui-lift-card', d.liftCard + 'px');
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px'); root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
// body classes // body classes
document.body.classList.toggle('no-scanlines', String(d.scanlines) !== '1');
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow'); document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
if (d.animSpeed === 'off') document.body.classList.add('anim-off'); if (d.animSpeed === 'off') document.body.classList.add('anim-off');
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast'); if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
@@ -1194,8 +1282,6 @@ if (str_starts_with($route, '/api/')) {
// Migrate legacy individual keys (only if no JSON blob exists yet) // Migrate legacy individual keys (only if no JSON blob exists yet)
if (!Object.keys(saved).length) { if (!Object.keys(saved).length) {
const legacy = {}; const legacy = {};
const sl = localStorage.getItem('hyprcrd-scanlines');
if (sl !== null) legacy.scanlines = sl;
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh; const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc; const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu; const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
@@ -1225,11 +1311,16 @@ if (str_starts_with($route, '/api/')) {
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span> <span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3"> <button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span> <i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd> <kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
</button>
<button 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>
</button> </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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"> <button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
@@ -1250,25 +1341,25 @@ if (str_starts_with($route, '/api/')) {
<div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg"> <div id="quick-capture" class="hidden px-6 py-2 border-t border-t-border bg-t-bg">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="ph ph-lightning text-t-accent text-sm"></i> <i class="ph ph-lightning text-t-accent text-sm"></i>
<input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono"> <input id="quick-capture-input" type="text" placeholder="quick capture — type and press Enter" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-sm focus:outline-none placeholder:text-t-dim font-mono">
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd> <kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 font-pixel">esc</kbd>
</div> </div>
</div> </div>
<div id="bucket-row" class="flex items-center px-6 py-1.5 relative"> <div id="bucket-row" class="flex items-center px-6 py-1.5 relative">
<div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div> <div class="flex items-center gap-1 overflow-x-auto flex-1 relative" id="bucket-bar"></div>
<button id="board-split-toggle" class="toolbar-toggle" title="split preview"> <button id="board-split-toggle" class="toolbar-toggle" data-help="Split preview\nShows a side panel with card preview,\nrelated cards, and backlinks.">
<i class="ph ph-split-horizontal text-sm"></i> <i class="ph ph-split-horizontal text-sm"></i>
</button> </button>
<button id="superview-toggle" class="toolbar-toggle" title="compact list view"> <button id="superview-toggle" class="toolbar-toggle" data-help="Superview\nCompact multi-column list with\nhover cards. Great for overviews.">
<i class="ph ph-list text-sm"></i> <i class="ph ph-list text-sm"></i>
</button> </button>
<button id="hide-done" class="toolbar-toggle" title="toggle done"> <button id="hide-done" class="toolbar-toggle" data-tip="toggle done">
<i class="ph ph-check-circle text-sm"></i> <i class="ph ph-check-circle text-sm"></i>
</button> </button>
<button id="hide-termine" class="toolbar-toggle" title="toggle appointments"> <button id="hide-termine" class="toolbar-toggle" data-tip="toggle appointments">
<i class="ph ph-calendar-x text-sm"></i> <i class="ph ph-calendar-x text-sm"></i>
</button> </button>
<button id="hide-tagged" class="toolbar-toggle" title="toggle tagged cards"> <button id="hide-tagged" class="toolbar-toggle" data-tip="toggle tagged cards">
<i class="ph ph-hash text-sm"></i> <i class="ph ph-hash text-sm"></i>
</button> </button>
<button id="sort-toggle" class="toolbar-toggle"> <button id="sort-toggle" class="toolbar-toggle">
@@ -1557,6 +1648,21 @@ if (str_starts_with($route, '/api/')) {
</table> </table>
</section> </section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
<p>The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
</div>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-shopping-cart text-xs"></i> pick active</td><td>header Shopping button → radio select → Save</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone access</td><td>relay UI → shopping tab (same login)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr>
</table>
</section>
<section> <section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3> <h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3>
<table class="w-full text-xs"> <table class="w-full text-xs">
@@ -1621,7 +1727,6 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
</div> </div>
<div id="theme-preview" class="theme-preview"> <div id="theme-preview" class="theme-preview">
<div class="tp-scan" id="theme-preview-scan"></div>
<div class="tp-row"> <div class="tp-row">
<div class="tp-card"> <div class="tp-card">
<div class="tp-card-bar"></div> <div class="tp-card-bar"></div>
@@ -1650,7 +1755,7 @@ if (str_starts_with($route, '/api/')) {
<div class="design-section-label">presets</div> <div class="design-section-label">presets</div>
<div class="design-row"> <div class="design-row">
<select id="design-preset-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer flex-1"></select> <select id="design-preset-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer flex-1"></select>
<button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" title="Delete user preset"><i class="ph ph-trash text-xs"></i></button> <button id="design-preset-delete" class="flex items-center gap-1 px-2 py-1 text-[10px] border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors" data-tip="delete user preset"><i class="ph ph-trash text-xs"></i></button>
</div> </div>
<div class="design-row"> <div class="design-row">
<input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none"> <input id="design-preset-name" type="text" placeholder="new preset name..." class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 flex-1 focus:border-t-accent focus:outline-none">
@@ -1668,7 +1773,6 @@ if (str_starts_with($route, '/api/')) {
</summary> </summary>
<div class="mt-2"> <div class="mt-2">
<div class="design-section-label">typography</div> <div class="design-section-label">typography</div>
<div class="toggle-row"><span class="toggle-label">Scan lines</span><div class="toggle-switch" id="setting-scanlines"></div></div>
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div> <div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div> <div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div> <div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
@@ -1693,7 +1797,7 @@ if (str_starts_with($route, '/api/')) {
<div class="design-section-label">effects</div> <div class="design-section-label">effects</div>
<div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div> <div class="design-row"><label>Card shadow</label><select id="setting-shadow-card" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div> <div class="design-row"><label>Modal shadow</label><select id="setting-shadow-modal" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div> <div class="design-row"><label>UI shadow</label><select id="setting-shadow-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="header & footer shadow"><option value="none">none</option><option value="soft">soft</option><option value="medium">medium</option><option value="heavy">heavy</option></select></div>
<div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div> <div class="design-row"><label>Card lift</label><input type="range" id="setting-lift-card" min="0" max="8" step="1" class="design-slider"><span class="design-val" id="val-lift-card">0</span></div>
<div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div> <div class="design-row"><label>Overlay blur</label><input type="range" id="setting-blur-overlay" min="0" max="16" step="1" class="design-slider"><span class="design-val" id="val-blur-overlay">0</span></div>
<div class="design-row"><label>Animation</label><select id="setting-anim-speed" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div> <div class="design-row"><label>Animation</label><select id="setting-anim-speed" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="off">off</option><option value="fast">fast</option><option value="normal">normal</option><option value="slow">slow</option></select></div>
@@ -1741,6 +1845,15 @@ if (str_starts_with($route, '/api/')) {
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button> <button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
</div> </div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div> <div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
<div class="border-t border-t-border pt-4 mt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
<div class="flex items-center gap-3">
<label class="text-xs text-t-muted" for="setting-sync-interval">Poll every</label>
<input id="setting-sync-interval" type="range" min="0" max="14" step="1" class="design-slider">
<span id="setting-sync-interval-val" class="text-[11px] text-t-muted" style="min-width:52px;text-align:right;flex-shrink:0;">15s</span>
</div>
</div>
</div> </div>
<div id="settings-panel-behavior" class="hidden space-y-5"> <div id="settings-panel-behavior" class="hidden space-y-5">
<div> <div>
@@ -1860,12 +1973,13 @@ if (str_starts_with($route, '/api/')) {
<div id="settings-panel-plugins" class="hidden space-y-4"> <div id="settings-panel-plugins" class="hidden space-y-4">
<div> <div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div> <div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.</p> <p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggle multiple plugins and apply them together — the page reloads once when you click Apply.</p>
<div id="plugins-list" class="space-y-2"></div> <div id="plugins-list" class="space-y-2"></div>
<div id="plugins-reload-notice" class="hidden mt-3 flex items-center gap-2 px-3 py-2 border border-t-accent bg-t-accent/10 text-xs text-t-accent"> <div id="plugins-apply-bar" class="hidden mt-3 flex items-center gap-3 px-3 py-2 border border-t-accent bg-t-accent/10">
<i class="ph ph-warning text-sm"></i> <i class="ph ph-warning text-sm text-t-accent"></i>
<span class="flex-1 font-pixel text-[10px]">reload required to apply changes</span> <span id="plugins-apply-count" class="flex-1 font-pixel text-[10px] text-t-accent"></span>
<button id="plugins-reload-btn" class="font-pixel text-[10px] underline hover:text-t-bright">Reload now</button> <button id="plugins-discard-btn" class="font-pixel text-[10px] text-t-muted hover:text-t-danger transition-colors">discard</button>
<button id="plugins-apply-btn" class="font-pixel text-[10px] px-3 py-1 bg-t-accent text-t-bg hover:brightness-110 transition-all">Apply</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1881,6 +1995,7 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
<div class="border-t border-t-border pt-4"> <div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div> <div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
<form id="settings-pw-form" onsubmit="return false;">
<div> <div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label> <label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"> <input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
@@ -1894,9 +2009,10 @@ if (str_starts_with($route, '/api/')) {
<input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"> <input id="settings-pw-confirm" type="password" autocomplete="new-password" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button id="settings-pw-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button> <button id="settings-pw-save" type="button" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Change password</span></button>
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span> <span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
</div> </div>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -1935,7 +2051,16 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-t-accent text-base"></i> <i class="ph ph-magnifying-glass text-t-accent text-base"></i>
<input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false"> <input type="text" id="search-input" placeholder="search cards..." class="flex-1 bg-transparent text-t-bright text-sm placeholder:text-t-dim font-mono" autocomplete="off" spellcheck="false">
<select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select> <select id="search-bucket-filter" class="bg-t-bg border border-t-border text-t-muted text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"><option value="">all buckets</option></select>
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="toggle replace"> <div class="relative">
<button id="search-saved-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-tip="saved searches">
<i class="ph ph-bookmark-simple text-sm"></i>
</button>
<div id="search-saved-popup" class="hidden absolute right-0 top-full mt-1 min-w-[220px] max-h-[280px] bg-t-surface border border-t-border shadow-2xl z-50 flex flex-col" style="border-radius:var(--ui-radius-modal,4px)">
<div id="search-saved-list" class="overflow-y-auto flex-1"></div>
<button id="search-saved-add" class="w-full text-left px-3 py-2 text-[10px] font-pixel text-t-accent border-t border-t-border hover:bg-t-raised transition-colors"><i class="ph ph-plus text-xs"></i> save current search</button>
</div>
</div>
<button id="search-replace-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Replace mode\nSearch and replace text across all\nmatching cards. Ctrl+Shift+H to open.">
<i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span> <i class="ph ph-swap text-sm"></i><span class="font-pixel text-[10px]">Replace</span>
</button> </button>
<button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"> <button id="search-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
@@ -2018,14 +2143,14 @@ if (str_starts_with($route, '/api/')) {
<button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"> <button id="detail-delete" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Del</span> <i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Del</span>
</button> </button>
<button id="detail-done" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="mark done"> <button id="detail-done" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="mark done">
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">Done</span> <i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]" id="detail-done-label">Done</span>
</button> </button>
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="zen mode"> <button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
<i class="ph ph-corners-out text-sm"></i> <i class="ph ph-corners-out text-sm"></i>
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="split view"> <button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span> <i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
</button> </button>
<button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"> <button id="detail-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
@@ -2064,14 +2189,16 @@ if (str_starts_with($route, '/api/')) {
<!-- Calendar --> <!-- Calendar -->
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar"> <div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;"> <div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0"> <div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
<i class="ph ph-calendar-dots text-t-accent text-base"></i> <i class="ph ph-calendar-dots text-t-accent text-base"></i>
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<h2 id="cal-title" class="font-pixel text-xs text-t-accent min-w-[120px] text-center"></h2>
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button> <button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button> <button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button> <button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<button id="cal-prev" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="previous"><i class="ph ph-caret-left text-base"></i></button>
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:280px;flex-shrink:0;"></h2>
<button id="cal-next" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="next"><i class="ph ph-caret-right text-base"></i></button>
</div>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"> <button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span> <i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
@@ -2169,11 +2296,11 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
</div> </div>
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0"> <div class="flex items-center gap-3 px-4 py-2.5 border-b border-t-border shrink-0">
<input type="text" id="inp-title" placeholder="> title..." class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel"> <input type="text" id="inp-title" placeholder="> title..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 bg-transparent border-none text-t-bright text-base font-bold focus:outline-none placeholder:text-t-dim font-pixel">
<button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="set due date"> <button id="inp-due-date" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="set due date">
<i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span> <i class="ph ph-clock-countdown text-xs"></i><span id="inp-due-label">No due</span>
</button> </button>
<button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" title="mark done"> <button id="inp-done-toggle" class="flex items-center gap-1 px-2 py-1 text-[10px] font-pixel border border-t-border text-t-dim hover:border-t-accent hover:text-t-accent transition-colors rounded-sm shrink-0" data-tip="mark done">
<i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">Done</span> <i class="ph ph-check-circle text-xs"></i><span id="inp-done-label">Done</span>
</button> </button>
<input type="hidden" id="inp-due-value" value=""> <input type="hidden" id="inp-due-value" value="">
@@ -2182,33 +2309,35 @@ if (str_starts_with($route, '/api/')) {
<input type="hidden" id="inp-color" value="#38bdf8"> <input type="hidden" id="inp-color" value="#38bdf8">
</div> </div>
<div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar"> <div class="flex items-center gap-1 px-4 py-1.5 border-b border-t-border shrink-0" id="md-toolbar">
<button data-md="heading" title="Heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button> <button data-md="heading" data-tip="heading" class="toolbar-btn"><i class="ph ph-text-h text-sm"></i></button>
<button data-md="bold" title="Bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button> <button data-md="bold" data-tip="bold" class="toolbar-btn"><i class="ph ph-text-bolder text-sm"></i></button>
<button data-md="italic" title="Italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button> <button data-md="italic" data-tip="italic" class="toolbar-btn"><i class="ph ph-text-italic text-sm"></i></button>
<button data-md="strikethrough" title="Strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button> <button data-md="strikethrough" data-tip="strikethrough" class="toolbar-btn"><i class="ph ph-text-strikethrough text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="code" title="Code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button> <button data-md="code" data-tip="code" class="toolbar-btn"><i class="ph ph-code text-sm"></i></button>
<button data-md="codeblock" title="Code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button> <button data-md="codeblock" data-tip="code block" class="toolbar-btn"><i class="ph ph-code-block text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="ul" title="List" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button> <button data-md="ul" data-tip="list" class="toolbar-btn"><i class="ph ph-list-bullets text-sm"></i></button>
<button data-md="tree" title="Tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button> <button data-md="tree" data-tip="tree list" class="toolbar-btn"><i class="ph ph-tree-structure text-sm"></i></button>
<button data-md="ol" title="Numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button> <button data-md="ol" data-tip="numbered" class="toolbar-btn"><i class="ph ph-list-numbers text-sm"></i></button>
<button data-md="quote" title="Quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button> <button data-md="quote" data-tip="quote" class="toolbar-btn"><i class="ph ph-quotes text-sm"></i></button>
<button data-md="todo" title="Todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button> <button data-md="todo" data-tip="todo" class="toolbar-btn"><i class="ph ph-check-square text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="link" title="Link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button> <button data-md="link" data-tip="link" class="toolbar-btn"><i class="ph ph-link text-sm"></i></button>
<button data-md="cardlink" title="Card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button> <button data-md="cardlink" data-tip="card ref [[" class="toolbar-btn"><i class="ph ph-arrow-square-out text-sm"></i></button>
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button> <button data-md="hr" data-tip="divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button> <button data-md="extract" data-help="Extract\nMoves selected text into a new card\nand inserts a [[link]] in its place." class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button> <button id="md-upload-pdf" data-help="Upload PDF\nSelect a PDF file to upload.\nInserts a download link into the card." class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden"> <input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
<button id="md-asset-picker" data-help="Insert from assets\nBrowse uploaded images, audio, and PDFs.\nClick a tile to insert into the editor." class="toolbar-btn" style="position:relative;"><i class="ph ph-images text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button> <button id="md-find-replace" data-help="Find & replace\nSearch and replace text within this card.\nCtrl+H to open, Ctrl+F for find-only." class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
<?= PluginManager::instance()->slot('md-toolbar') ?> <?= PluginManager::instance()->slot('md-toolbar') ?>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button> <button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button> <button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
<button id="toggle-scratchpad" class="toolbar-btn flex items-center gap-1.5 px-2" data-help="Scratchpad\nPrivate notes about this card.\nSaved separately, not part of the content."><i class="ph ph-notepad text-sm"></i><span class="font-pixel text-[9px]">Scratch</span></button>
</div> </div>
<div class="flex flex-1 min-h-0 overflow-hidden"> <div class="flex flex-1 min-h-0 overflow-hidden">
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div> <div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
@@ -2219,9 +2348,9 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i> <i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;"> <input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span> <span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button> <button id="find-prev" class="toolbar-btn" data-tip="previous"><i class="ph ph-caret-up text-xs"></i></button>
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button> <button id="find-next" class="toolbar-btn" data-tip="next"><i class="ph ph-caret-down text-xs"></i></button>
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button> <button id="find-close" class="toolbar-btn" data-tip="close"><i class="ph ph-x text-xs"></i></button>
</div> </div>
<div id="replace-row" class="flex items-center gap-2"> <div id="replace-row" class="flex items-center gap-2">
<i class="ph ph-swap text-xs text-t-dim"></i> <i class="ph ph-swap text-xs text-t-dim"></i>
@@ -2232,11 +2361,19 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
<div class="relative flex-1 min-h-0"> <div class="relative flex-1 min-h-0">
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div> <div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea> <textarea id="editor-body" placeholder="> write markdown..." spellcheck="false" autocorrect="off" autocapitalize="off" class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
</div> </div>
</div> </div>
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div> <div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div> <div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
<div id="editor-scratchpad" class="bg-t-bg flex flex-col hidden" style="flex:1 1 0%;min-width:0;">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-t-border shrink-0">
<i class="ph ph-notepad text-xs text-t-dim"></i>
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
</div>
<textarea id="scratchpad-body" placeholder="private notes..." spellcheck="false" autocorrect="off" autocapitalize="off" class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
</div>
</div> </div>
</div> </div>
</div> </div>
+41 -11
View File
@@ -13,11 +13,13 @@ import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './module
import { openSearch, closeSearch } from './modules/search.js'; import { openSearch, closeSearch } from './modules/search.js';
import { showContextMenu } from './modules/context-menu.js'; import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js'; import { undo, canUndo } from './modules/undo.js';
import './modules/tooltip.js';
import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js'; import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js';
import { openGallery, closeGallery } from './modules/gallery.js'; import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js'; import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js'; import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
import { openPluginSettings } from './modules/plugin-settings.js'; import { openPluginSettings } from './modules/plugin-settings.js';
// ── Global API (for inline onclick handlers and cross-module calls) ── // ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -40,7 +42,17 @@ window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection; window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView; window._refreshTasks = refreshTasksView;
window.openFindBar = openFindBar; window.openFindBar = openFindBar;
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); }; window._reloadAll = async () => {
await loadBuckets();
state.cards = await api('api/cards') || [];
state.cardsLoaded = true;
renderBoard();
refreshCalendar();
// Let plugins / modules react to a full card reload (detail view checks
// whether the currently viewed card is now stale; collab-buckets uses it
// to refresh its decorators).
window.hyprcrd?.hooks?.emit?.('cards.reloaded');
};
// Exposed for plugins that need to read the shared state (e.g. collab-buckets // Exposed for plugins that need to read the shared state (e.g. collab-buckets
// reads `state.buckets` in its MutationObserver to find collab_uid mapping). // reads `state.buckets` in its MutationObserver to find collab_uid mapping).
window.state = state; window.state = state;
@@ -75,10 +87,14 @@ function setCachedCards(cards) {
} }
async function loadCards() { async function loadCards() {
// Phase 0: Show cached cards instantly (from previous session) // Phase 0: Show cached cards instantly (from previous session).
// A hit here is enough to flip cardsLoaded — fresh meta will
// overwrite state.cards a moment later, but the spinner→content
// transition already happened.
const cached = getCachedCards(); const cached = getCachedCards();
if (cached.length) { if (cached.length) {
state.cards = cached.map(c => ({ ...c, body: null })); state.cards = cached.map(c => ({ ...c, body: null }));
state.cardsLoaded = true;
updateSortUI(); updateSortUI();
renderBoard(); renderBoard();
} }
@@ -86,6 +102,7 @@ async function loadCards() {
// Phase 1: Load metadata → update board with fresh titles/tags/dates // Phase 1: Load metadata → update board with fresh titles/tags/dates
const meta = await api('api/cards/meta'); const meta = await api('api/cards/meta');
if (!meta) return; if (!meta) return;
state.cardsLoaded = true;
state.cards = meta.map(c => ({ ...c, body: null })); state.cards = meta.map(c => ({ ...c, body: null }));
setCachedCards(meta); setCachedCards(meta);
updateSortUI(); updateSortUI();
@@ -143,10 +160,14 @@ importFile?.addEventListener('change', async e => {
try { try {
const url = mode ? 'api/import?merge=1' : 'api/import'; const url = mode ? 'api/import?merge=1' : 'api/import';
const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const data = JSON.parse(await res.text()); const text = await res.text();
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
const data = JSON.parse(text);
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard();
} catch {} } catch (err) {
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
}
e.target.value = ''; e.target.value = '';
}); });
@@ -209,7 +230,8 @@ const { board: gBoard } = guards;
// — Escape chain (highest priority first) — // — Escape chain (highest priority first) —
registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } }); registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } });
registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } }); registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { if (document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
registerShortcut({ key: 'z', guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'detail', keys: '<kbd>Z</kbd>', label: 'toggle zen mode', handler: () => document.getElementById('detail-zen')?.click() });
registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() }); registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() });
registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() }); registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() });
registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() }); registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() });
@@ -266,6 +288,8 @@ registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J
registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() }); registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } }); registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' }); registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: '<kbd>W</kbd>', label: 'watch', handler: () => window._watchToggle?.() });
registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: '<kbd>I</kbd>', label: 'tim (time calculator)', handler: () => window._timToggle?.() });
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() }); registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => { registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', label: 'cycle buckets', handler: (e) => {
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]; const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
@@ -310,17 +334,23 @@ registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelect
const focused = document.querySelector('#board .card-focused'); const focused = document.querySelector('#board .card-focused');
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); } if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
}}); }});
// Delete selected cards // Delete selected or focused card
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => { registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && (getSelectedIds().length > 0 || !!document.querySelector('#board .card-focused')), section: 'global', keys: '<kbd>⌫</kbd>', label: 'delete selected / focused', hidden: true, handler: async () => {
const ids = getSelectedIds(); let ids = getSelectedIds();
if (!ids.length) {
const focused = document.querySelector('#board .card-focused');
if (focused) ids = [focused.dataset.id];
}
if (!ids.length) return;
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' }); const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
if (!ok) return; if (!ok) return;
} }
const { animateCardRemoval } = await import('./modules/board.js'); const { animateCardRemoval, clearCardFocus } = await import('./modules/board.js');
await animateCardRemoval(ids); await animateCardRemoval(ids);
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' }); for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
clearSelection(); clearSelection();
clearCardFocus();
await window._reloadAll(); await window._reloadAll();
}}); }});
+33 -5
View File
@@ -584,10 +584,21 @@ export function renderBoard() {
renderGroupBar(cards); renderGroupBar(cards);
if (!cards.length) { if (!cards.length) {
board.style.display = ''; board.style.display = 'flex';
board.innerHTML = `<div class="col-span-full text-center py-20"> board.style.alignItems = 'center';
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i> board.style.justifyContent = 'center';
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`; // Distinguish "genuinely empty" from "haven't heard back yet".
// Blurting "no cards found" during the initial load is scary
// on boards the user knows aren't empty.
if (!state.cardsLoaded) {
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
} else {
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
}
const fc = $('footer-count'); const fc = $('footer-count');
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`; if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
return; return;
@@ -707,6 +718,8 @@ export function renderBoard() {
} }
board.style.display = ''; board.style.display = '';
board.style.alignItems = '';
board.style.justifyContent = '';
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join(''); board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
bindCardEvents(); bindCardEvents();
@@ -755,9 +768,24 @@ export function renderBoard() {
// Pulse locally-created cards in ANY bucket (1× subtle pulse). // Pulse locally-created cards in ANY bucket (1× subtle pulse).
// These never enter unseenCardIds so they can't trigger the all-tab // These never enter unseenCardIds so they can't trigger the all-tab
// dot — the user just made them, there's nothing "unseen" about it. // dot — the user just made them, there's nothing "unseen" about it.
// Journal entries are exempt from the pulse entirely — they're daily
// log jottings, not "here's a new thing" moments, so the flash
// reads as noise. Detection mirrors calendar.js isJournal: title
// begins with YYYY-MM-DD, no termin/geburtstag tag, no caldav_uid,
// and no " — <summary>" suffix (which denotes an appointment).
if (newLocalIds.size) { if (newLocalIds.size) {
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id); for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
pulseCards(newLocalIds, 'card-pulse-local'); const toPulse = new Set();
for (const id of newLocalIds) {
const card = state.cards.find(c => String(c.id) === id);
if (!card) continue;
const isJournal = /^\d{4}-\d{2}-\d{2}$/.test((card.title || '').trim())
&& !/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title || '')
&& !card.caldav_uid
&& !(card.tags || '').split(',').some(t => t === 'termin' || t === 'geburtstag');
if (!isJournal) toPulse.add(id);
}
if (toPulse.size) pulseCards(toPulse, 'card-pulse-local');
} }
// Remote (relay-synced) cards: pulse 3× only when the user is on // Remote (relay-synced) cards: pulse 3× only when the user is on
// the all view. On other views, the unseen ids accumulate and // the all view. On other views, the unseen ids accumulate and
+65 -13
View File
@@ -186,6 +186,43 @@ const inpBucket = $('inp-bucket');
const searchFilter = $('search-bucket-filter'); const searchFilter = $('search-bucket-filter');
const tasksFilter = $('tasks-bucket-filter'); const tasksFilter = $('tasks-bucket-filter');
// ── Overflow handling for the bucket bar ─────────────────────────
// Scrollbar is hidden (CSS). Mouse-wheel over the bar translates to
// horizontal scroll so long bucket rows stay usable without a bar.
// Edge fades kick in dynamically based on scroll position so the
// user sees there's more off-screen — both sides at once in the
// middle, one side at either extreme. Fade overlays live on the
// parent #bucket-row (see CSS) so they don't clip super-bucket
// popovers that render inside #bucket-bar itself.
const barRow = $('bucket-row');
function updateBucketBarFades() {
if (!bar || !barRow) return;
const canLeft = bar.scrollLeft > 2;
const canRight = bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 2;
barRow.classList.toggle('fade-left', canLeft && !canRight);
barRow.classList.toggle('fade-right', !canLeft && canRight);
barRow.classList.toggle('fade-both', canLeft && canRight);
// Right-side fade overlay is anchored to the parent's right edge;
// the actual bar ends before the toolbar toggles. Compute the
// distance so the gradient lines up with the bar's right edge.
const rowRect = barRow.getBoundingClientRect();
const barRect = bar.getBoundingClientRect();
const rightInset = Math.max(0, rowRect.right - barRect.right);
barRow.style.setProperty('--bucket-row-right-inset', rightInset + 'px');
}
if (bar) {
bar.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (bar.scrollWidth <= bar.clientWidth) return;
e.preventDefault();
bar.scrollLeft += e.deltaY;
}, { passive: false });
bar.addEventListener('scroll', updateBucketBarFades, { passive: true });
window.addEventListener('resize', updateBucketBarFades);
new MutationObserver(updateBucketBarFades).observe(bar, { childList: true, subtree: false });
updateBucketBarFades();
}
/** Switch the active bucket without going through the click path — /** Switch the active bucket without going through the click path —
* used by the number-key hotkeys (and any other code that wants to * used by the number-key hotkeys (and any other code that wants to
* activate a bucket programmatically without triggering the super- * activate a bucket programmatically without triggering the super-
@@ -294,11 +331,11 @@ function renderBar() {
// render (e.g. bucket reorder, plain renderBar() call). // render (e.g. bucket reorder, plain renderBar() call).
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0; const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
bar.innerHTML = ` bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button> <button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" data-tip="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button> <button class="${tab(state.activeBucket === 'current')}" data-bucket="current" data-tip="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button> <button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" data-tip="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button> <button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" data-tip="journal"><i class="ph ph-notebook text-sm"></i></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button> <button class="${tab(state.activeBucket === 'none')}" data-bucket="none" data-tip="unsorted"><i class="ph ph-tray text-sm"></i></button>
${topLevel.map(b => { ${topLevel.map(b => {
const kids = childrenOf(b.id); const kids = childrenOf(b.id);
const isSuper = kids.length > 0; const isSuper = kids.length > 0;
@@ -309,7 +346,7 @@ function renderBar() {
const shown = tileFor(b); const shown = tileFor(b);
const c = shown.color || '#64748b'; const c = shown.color || '#64748b';
const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket); const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket);
return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></button>`; return `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><span class="super-caret" data-super-caret="${b.id}"><i class="ph ph-caret-down text-[10px]"></i></span></button>`;
} }
const c = b.color || '#64748b'; const c = b.color || '#64748b';
const active = state.activeBucket == b.id; const active = state.activeBucket == b.id;
@@ -400,7 +437,7 @@ function renderBar() {
const c = m.color || '#64748b'; const c = m.color || '#64748b';
const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id); const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id);
const isParent = m.id === parentId; const isParent = m.id === parentId;
return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" title="parent"></i>' : ''}</button>`; return `<button class="bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" data-tip="parent"></i>' : ''}</button>`;
}; };
pop.innerHTML = members.map(renderEntry).join(''); pop.innerHTML = members.map(renderEntry).join('');
// Append inside #bucket-bar (not document.body) so the collab // Append inside #bucket-bar (not document.body) so the collab
@@ -462,11 +499,21 @@ function renderBar() {
const isUser = isSuperTile || !SPECIAL.includes(bid); const isUser = isSuperTile || !SPECIAL.includes(bid);
if (isSuperTile) { if (isSuperTile) {
// Click opens the dropdown popover instead of activating the // Caret arrow opens the dropdown; clicking the rest of the
// parent directly. The dropdown lists parent + every child. // tab activates the last-shown child (or parent).
t.addEventListener('click', (e) => { const caret = t.querySelector('[data-super-caret]');
e.stopPropagation(); if (caret) {
openSuperDropdown(t, parseInt(bid, 10)); caret.addEventListener('click', (e) => {
e.stopPropagation();
openSuperDropdown(t, parseInt(bid, 10));
});
}
t.addEventListener('click', () => {
const shownId = t.dataset.superShown;
setActiveBucket(shownId || bid);
state.activeGroup = null;
renderBar();
window._renderBoard();
}); });
} else { } else {
t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); }); t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); });
@@ -598,8 +645,13 @@ function renderBar() {
} }
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
await loadBuckets(); await loadBuckets();
if (created?.id) {
setActiveBucket(created.id);
state.activeGroup = null;
}
renderBar();
window._renderBoard(); window._renderBoard();
}); });
+9 -171
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 { $, esc, api, getCsrf } from './util.js';
import { state } from './state.js';
const CAL_ICONS = [ const CAL_ICONS = [
{ id: 'calendar-dots', label: 'calendar' }, { id: 'calendar-dots', label: 'calendar' },
@@ -76,7 +82,7 @@ function renderCalList(calendars) {
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')} ${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
</div> </div>
<div class="flex gap-1 ml-1"> <div class="flex gap-1 ml-1">
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" title="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')} ${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" data-tip="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -119,7 +125,6 @@ export async function loadSettings() {
} }
} }
function setCaldavTestStatus(text, kind = 'info') { function setCaldavTestStatus(text, kind = 'info') {
if (!testStatus) return; if (!testStatus) return;
if (!text) { if (!text) {
@@ -170,52 +175,6 @@ $('caldav-save')?.addEventListener('click', async () => {
testStatus.style.color = 'var(--t-accent)'; testStatus.style.color = 'var(--t-accent)';
}); });
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
export async function triggerSync() {
const btn = $('cal-sync');
if (btn) btn.style.opacity = '0.5';
const headerIcon = $('header-sync-icon');
if (headerIcon) headerIcon.classList.add('card-spinner');
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/caldav/sync', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
if (btn) btn.style.opacity = '';
// Also poll relay
// (header spinner stays on until the relay poll completes too — see end)
const relayRes = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (res.ok) {
const s = res.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
const allLog = res.log || [];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(allLog);
showSyncNotification(total);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
if (headerIcon) headerIcon.classList.remove('card-spinner');
}
function showLog(log) {
if (!logEl || !log?.length) return;
logEl.classList.remove('hidden');
logEl.innerHTML = log.map(l =>
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
).join('');
}
function showConnectionLog(lines) { function showConnectionLog(lines) {
if (!logEl || !lines?.length) return; if (!logEl || !lines?.length) return;
logEl.classList.remove('hidden'); logEl.classList.remove('hidden');
@@ -224,125 +183,4 @@ function showConnectionLog(lines) {
).join(''); ).join('');
} }
// ── Relay settings ──
function setRelayStatus(text, kind = 'info') {
const el = $('relay-status');
if (!el) return;
if (!text) {
el.classList.add('hidden');
el.textContent = '';
el.style.borderColor = '';
el.style.color = '';
return;
}
el.classList.remove('hidden');
el.textContent = text;
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
}
$('relay-save')?.addEventListener('click', async () => {
const body = {
url: $('relay-url')?.value || '',
username: $('relay-username')?.value || '',
key: $('relay-key')?.value || '',
};
setRelayStatus('saving...');
await api('api/relay/settings', { method: 'POST', body });
setRelayStatus('saved', 'success');
setTimeout(() => setRelayStatus(''), 1500);
});
$('relay-test')?.addEventListener('click', async () => {
setRelayStatus('testing...');
const res = await fetch('api/relay/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ strict: true }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
if (res.pulled) await window._reloadAll?.();
} else {
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
setRelayStatus((res?.error || 'failed') + detail, 'error');
}
});
export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const userInput = $('relay-username');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings); $('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
$('cal-sync')?.addEventListener('click', () => triggerSync());
$('header-sync')?.addEventListener('click', () => triggerSync());
// Auto-sync throttle
let lastAutoSync = 0;
export function autoSync() {
if (window._testRunning) return;
const now = Date.now();
if (now - lastAutoSync < 60000) return;
lastAutoSync = now;
triggerSync();
}
// Background sync every 2 minutes
function showSyncNotification(stats) {
const s = stats || {};
const parts = [];
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.deleted) parts.push(`${s.deleted} deleted`);
if (!parts.length) return;
const note = document.createElement('div');
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
document.body.appendChild(note);
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
}
async function backgroundSync() {
if (state.bgSyncRunning) return;
state.bgSyncRunning = true;
try {
const headers = { 'X-CSRF-Token': getCsrf() };
const [res, relayRes] = await Promise.all([
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const s = res?.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
await window._reloadAll?.();
showSyncNotification(total);
}
} catch {} finally {
state.bgSyncRunning = false;
}
}
// Start background sync after app is ready
let bgSyncInterval = null;
export function startBackgroundSync() {
if (bgSyncInterval) return;
backgroundSync();
bgSyncInterval = setInterval(backgroundSync, 45000); // every 45 seconds
}
export function stopBackgroundSync() {
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
}
+54 -13
View File
@@ -3,7 +3,8 @@
import { $, esc } from './util.js'; import { $, esc } from './util.js';
import { state } from './state.js'; import { state } from './state.js';
import { stripMd, getTodos } from './markdown.js'; import { stripMd, getTodos } from './markdown.js';
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js'; import { autoSync } from './sync.js';
import { getCalConfig, getAllCalConfigs } from './caldav.js';
import { renderCardHtml } from './board.js'; import { renderCardHtml } from './board.js';
const overlay = $('calendar'); const overlay = $('calendar');
@@ -88,20 +89,35 @@ function matchesRRule(card, targetDateStr) {
return false; return false;
} }
function getMultiDayEnd(card) {
const m = (card.body || '').match(/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})/i);
return m ? m[1] : null;
}
function getCardsForDate(ds) { function getCardsForDate(ds) {
const results = []; const results = [];
const targetYear = parseInt(ds.substring(0, 4)); const targetYear = parseInt(ds.substring(0, 4));
for (const c of state.cards) { for (const c of state.cards) {
if (cardBaseDate(c) === ds) { const base = cardBaseDate(c);
if (base === ds) {
results.push({ ...c, _recurring: false }); results.push({ ...c, _recurring: false });
} else if (c.caldav_rrule && matchesRRule(c, ds)) { } else if (base && base < ds) {
const baseYear = parseInt(cardBaseDate(c).substring(0, 4)); // Multi-day event: check if ds is within [base..end]
const end = getMultiDayEnd(c);
if (end && ds <= end) {
const startMs = Date.parse(base);
const curMs = Date.parse(ds);
const dayNum = Math.round((curMs - startMs) / 86400000) + 1;
const totalDays = Math.round((Date.parse(end) - startMs) / 86400000) + 1;
results.push({ ...c, _recurring: false, _multiDay: { day: dayNum, total: totalDays, isEnd: ds === end } });
}
}
if (c.caldav_rrule && matchesRRule(c, ds)) {
const baseYear = parseInt(base.substring(0, 4));
const rrule = parseRRule(c.caldav_rrule); const rrule = parseRRule(c.caldav_rrule);
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null; const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
results.push({ ...c, _recurring: true, _age: age }); if (base !== ds) results.push({ ...c, _recurring: true, _age: age });
} else if ((c.tags || '').split(',').includes('geburtstag')) { } else if ((c.tags || '').split(',').includes('geburtstag')) {
// Local birthday cards repeat yearly without RRULE
const base = cardBaseDate(c);
if (base.substring(5) === ds.substring(5) && ds !== base) { if (base.substring(5) === ds.substring(5) && ds !== base) {
const age = targetYear - parseInt(base.substring(0, 4)); const age = targetYear - parseInt(base.substring(0, 4));
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null }); results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
@@ -146,7 +162,25 @@ function clearCalHover() {
} }
function render() { function render() {
title.textContent = `${MONTHS[state.calMonth]} ${state.calYear}`; // Format: [MM-DD] Mon Y [KW-KW]
// MM-DD = today's month-day
// Mon Y = displayed month + year (abbreviated)
// KW-KW = ISO week range spanning the displayed month
const isoWeek = (y, m, d) => {
const dt = new Date(Date.UTC(y, m, d));
const dayNum = dt.getUTCDay() || 7;
dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
return Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
};
const pad = n => String(n).padStart(2, '0');
const nowDate = new Date();
const todayMd = `${pad(nowDate.getMonth() + 1)}-${pad(nowDate.getDate())}`;
const monAbbr = MONTHS[state.calMonth].substring(0, 3);
const lastDay = new Date(state.calYear, state.calMonth + 1, 0).getDate();
const wStart = isoWeek(state.calYear, state.calMonth, 1);
const wEnd = isoWeek(state.calYear, state.calMonth, lastDay);
title.innerHTML = `<span style="color:var(--t-dim)">[${todayMd}]</span> ${monAbbr} ${state.calYear} <span style="color:var(--t-dim)">[${pad(wStart)}-${pad(wEnd)}]</span>`;
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
const startOffset = (firstDay + 6) % 7; // Mon=0 const startOffset = (firstDay + 6) % 7; // Mon=0
@@ -210,9 +244,11 @@ function render() {
const calCfg = getCardCalConfig(c); const calCfg = getCardCalConfig(c);
const itemColor = calCfg?.color || c.color; const itemColor = calCfg?.color || c.color;
const itemIcon = calCfg?.icon || null; const itemIcon = calCfg?.icon || null;
const md = c._multiDay;
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`; html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`; if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`; if (md) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:${itemColor};flex-shrink:0;">${md.day}/${md.total}</span>`;
else if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`; if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
html += `<span class="truncate">${esc(name)}</span>`; html += `<span class="truncate">${esc(name)}</span>`;
html += `</div>`; html += `</div>`;
@@ -457,7 +493,7 @@ function openDayPopup(ds, cellEl) {
const cfg = calSlug ? getCalConfig(calSlug) : null; const cfg = calSlug ? getCalConfig(calSlug) : null;
const col = cfg?.color || '#fb923c'; const col = cfg?.color || '#fb923c';
const calTag = calSlug ? ` #${calSlug}` : ''; const calTag = calSlug ? ` #${calSlug}` : '';
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`; const body = `**Zeit:** \n**Bis:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null }); window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
} else if (type === 'birthday') { } else if (type === 'birthday') {
const body = `#geburtstag`; const body = `#geburtstag`;
@@ -494,9 +530,14 @@ export function refreshCalendar() {
} }
export function openCalendar() { export function openCalendar() {
const now = new Date(); // Only jump to today on first open — preserve the user's navigation
state.calYear = now.getFullYear(); // when the calendar is re-opened (e.g. after saving a card in a
state.calMonth = now.getMonth(); // different month).
if (state.calYear == null || state.calMonth == null) {
const now = new Date();
state.calYear = now.getFullYear();
state.calMonth = now.getMonth();
}
render(); render();
overlay.classList.add('open'); overlay.classList.add('open');
overlay.style.visibility = ''; overlay.style.visibility = '';
+26 -18
View File
@@ -195,36 +195,44 @@ export function showContextMenu(x, y, card) {
</button>` : ''); </button>` : '');
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => { tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation(); e.preventDefault();
const targetId = card.id;
const tag = btn.dataset.ctxTag; const tag = btn.dataset.ctxTag;
const has = cardTags.includes(tag); // Re-read the current card from state to avoid stale body
let newBody = card.body || ''; const fresh = state.cards.find(x => x.id == targetId) || card;
const freshTags = parseTags(fresh);
const has = freshTags.includes(tag);
let newBody = fresh.body || '';
if (has) { if (has) {
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), ''); newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
cardTags.splice(cardTags.indexOf(tag), 1);
} else { } else {
newBody = newBody.trimEnd() + ' #' + tag; newBody = newBody.trimEnd() + ' #' + tag;
cardTags.push(tag);
} }
card.body = newBody; const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } }); // Trust the server's response for the new tags string
const c = state.cards.find(x => x.id == card.id); const c = state.cards.find(x => x.id == targetId);
if (c) { c.body = newBody; c.tags = cardTags.join(','); } if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
// Update our local working copy
card.body = c?.body ?? newBody;
cardTags.length = 0;
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
renderCtxTags(tagInput.value); renderCtxTags(tagInput.value);
window._renderBoard(); window._renderBoard();
})); }));
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => { tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation(); e.preventDefault();
const targetId = card.id;
const tag = btn.dataset.ctxTagNew; const tag = btn.dataset.ctxTagNew;
const newBody = (card.body || '').trimEnd() + ' #' + tag; const fresh = state.cards.find(x => x.id == targetId) || card;
card.body = newBody; const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
cardTags.push(tag); const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
allTags.push(tag); const c = state.cards.find(x => x.id == targetId);
allTags.sort(); if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } }); card.body = c?.body ?? newBody;
const c = state.cards.find(x => x.id == card.id); cardTags.length = 0;
if (c) { c.body = newBody; c.tags = cardTags.join(','); } cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
tagInput.value = ''; tagInput.value = '';
renderCtxTags(''); renderCtxTags('');
window._renderBoard(); window._renderBoard();
+100 -4
View File
@@ -72,9 +72,104 @@ export function openDetail(id, options = {}) {
renderSidebar(card); renderSidebar(card);
showContent(card); showContent(card);
dismissStaleBanner();
detail.classList.add('open'); detail.classList.add('open');
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
} }
// ── Remote-update detection ──
//
// When a foreign edit arrives for the card the user is currently viewing,
// the pull replaces state.cards but the open detail view keeps rendering
// the stale object it captured on open. Instead of force-re-rendering
// under the user's finger (jarring when they're mid-read), surface a
// banner with a manual Reload button.
let staleBannerEl = null;
function dismissStaleBanner() {
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
}
function showStaleBanner(fresh) {
dismissStaleBanner();
const host = detail.querySelector('#detail-controls')?.parentElement;
if (!host) return;
const bar = document.createElement('div');
bar.className = 'detail-stale-banner';
bar.innerHTML = `
<i class="ph ph-info"></i>
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
<button class="detail-stale-reload">
<i class="ph ph-arrows-clockwise text-xs"></i>
<span>Reload</span>
</button>
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
`;
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
staleBannerEl = bar;
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
const current = state.cards.find(c => c.id == state.viewingCard?.id);
if (current) {
state.viewingCard = current;
renderSidebar(current);
showContent(current);
// Treat the reload as an explicit "I've now seen the new version"
// — re-fire detail.opened so collab-buckets clears the red dot
// on the board card and persists mark-card-seen to the backend.
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
}
dismissStaleBanner();
});
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
}
function timeAgo(iso) {
if (!iso) return '';
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
// local time — off by the tz offset (2h on CEST, hence "2h ago"
// for a 30-second-old update).
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
? iso
: iso.replace(' ', 'T') + 'Z';
const t = new Date(normalized).getTime();
if (!isFinite(t)) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return `${Math.floor(s)}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
// After every full card reload, check whether the card we're viewing is
// newer in the fresh state. If so, show the banner. Dismiss it if the
// detail view closed in the meantime.
function checkStaleViewingCard() {
if (!detail.classList.contains('open') || !state.viewingCard) {
dismissStaleBanner();
return;
}
const viewingId = state.viewingCard.id;
const fresh = state.cards.find(c => c.id == viewingId);
if (!fresh) return; // card deleted — separate UX, skip banner
const old = state.viewingCard;
// Compare updated_at OR any field that might have changed. The detail
// view captures viewingCard by reference on open, so a replaced
// state.cards array (via _reloadAll) gives us a different object — if
// any visible field differs, surface the banner.
const changed =
(fresh.updated_at || '') > (old.updated_at || '') ||
(fresh.title || '') !== (old.title || '') ||
(fresh.body || '') !== (old.body || '') ||
(fresh.color || '') !== (old.color || '') ||
(fresh.tags || '') !== (old.tags || '');
if (changed) showStaleBanner(fresh);
}
window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard);
function clearMorphGhost() { function clearMorphGhost() {
if (morphTimer) { if (morphTimer) {
clearTimeout(morphTimer); clearTimeout(morphTimer);
@@ -370,6 +465,7 @@ export function closeDetail(options = {}) {
closeImageFullscreen(); closeImageFullscreen();
detail.classList.remove('open', 'zen'); detail.classList.remove('open', 'zen');
dismissStaleBanner();
removeZenHandles(); removeZenHandles();
document.documentElement.classList.remove('detail-zen-open'); document.documentElement.classList.remove('detail-zen-open');
document.body.classList.remove('detail-zen-open'); document.body.classList.remove('detail-zen-open');
@@ -433,7 +529,7 @@ function renderSidebar(card) {
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span> <span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''} ${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span> <span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
<button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" title="ungroup"> <button onclick="event.stopPropagation();window._ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == groupCards[0]?.id ? 'invisible' : ''}" data-tip="ungroup">
<i class="ph ph-x text-[10px]"></i> <i class="ph ph-x text-[10px]"></i>
</button> </button>
</div>`; </div>`;
@@ -554,7 +650,7 @@ function applyReadingResizeHandles(root) {
const line = document.createElement('button'); const line = document.createElement('button');
line.type = 'button'; line.type = 'button';
line.className = 'detail-font-dragline'; line.className = 'detail-font-dragline';
line.title = 'drag to resize text'; line.dataset.tip = 'drag to resize text';
line.setAttribute('aria-label', 'resize text'); line.setAttribute('aria-label', 'resize text');
el.appendChild(line); el.appendChild(line);
@@ -615,7 +711,7 @@ export function bindDetailImageFullscreen(root) {
// Regular images: double-click, no gallery nav // Regular images: double-click, no gallery nav
root.querySelectorAll('img').forEach(img => { root.querySelectorAll('img').forEach(img => {
if (img.closest('.md-gallery')) return; if (img.closest('.md-gallery')) return;
img.title = 'double-click to open fullscreen'; img.dataset.tip = 'double-click to fullscreen';
img.addEventListener('dblclick', e => { img.addEventListener('dblclick', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -898,7 +994,7 @@ function loadSplitCard(id) {
<button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors"> <button id="split-back" class="flex items-center gap-1.5 flex-1 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span> <i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">${splitLastQuery ? 'results' : 'related cards'}</span>
</button> </button>
<button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" title="open as main card"> <button id="split-promote" class="flex items-center gap-1.5 px-3 py-2 text-xs text-t-muted hover:text-t-accent hover:bg-t-raised transition-colors" data-tip="open as main card">
<i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span> <i class="ph ph-arrow-line-left text-sm"></i><span class="font-pixel text-[10px]">Focus</span>
</button> </button>
</div>`; </div>`;
+235 -17
View File
@@ -4,6 +4,7 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
import { state, getChildren, CARD_COLORS } from './state.js'; import { state, getChildren, CARD_COLORS } from './state.js';
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js'; import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
import { pushCardEdit, snapshotCard } from './undo.js'; import { pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
const modal = $('modal'); const modal = $('modal');
const body = $('editor-body'); const body = $('editor-body');
@@ -13,6 +14,8 @@ const inpTitle = $('inp-title');
const inpColor = $('inp-color'); const inpColor = $('inp-color');
const inpBucket = $('inp-bucket'); const inpBucket = $('inp-bucket');
const presets = $('color-presets'); const presets = $('color-presets');
const scratchpadPanel = $('editor-scratchpad');
const scratchpadBody = $('scratchpad-body');
const linkAc = $('link-ac'); const linkAc = $('link-ac');
const tagAc = $('tag-ac'); const tagAc = $('tag-ac');
@@ -102,7 +105,7 @@ function startCloseMorph() {
CARD_COLORS.forEach(c => { CARD_COLORS.forEach(c => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.title = c.name; btn.type = 'button'; btn.dataset.tip = c.name;
btn.dataset.color = c.hex; btn.dataset.color = c.hex;
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch'; btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
btn.style.background = c.hex; btn.style.background = c.hex;
@@ -176,6 +179,45 @@ function selectColor(hex) {
// ── Open / close ── // ── Open / close ──
// ── Autobackup (draft recovery) ──
const DRAFT_KEY = 'hyprcrd-editor-draft';
let draftSaveTimer = null;
function draftKeyFor(cardId) {
return DRAFT_KEY + ':' + (cardId || 'new');
}
function loadDraft(cardId) {
try {
const raw = localStorage.getItem(draftKeyFor(cardId));
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function saveDraftDebounced() {
clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
const id = state.editingId || 'new';
const data = {
title: inpTitle.value,
body: body.value,
scratchpad: scratchpadBody?.value || '',
savedAt: Date.now(),
};
// Don't store empty drafts
if (!data.title.trim() && !data.body.trim() && !data.scratchpad.trim()) {
localStorage.removeItem(draftKeyFor(id));
return;
}
try { localStorage.setItem(draftKeyFor(id), JSON.stringify(data)); } catch {}
}, 800);
}
function clearDraft(cardId) {
clearTimeout(draftSaveTimer);
try { localStorage.removeItem(draftKeyFor(cardId || 'new')); } catch {}
}
export function openModal(card = null) { export function openModal(card = null) {
state.editingId = card ? card.id : null; state.editingId = card ? card.id : null;
$('modal-title').textContent = card ? 'edit_card' : 'new_card'; $('modal-title').textContent = card ? 'edit_card' : 'new_card';
@@ -184,30 +226,74 @@ export function openModal(card = null) {
inpTitle.value = card ? card.title : (isJournal ? today : ''); inpTitle.value = card ? card.title : (isJournal ? today : '');
body.value = card ? card.body : ''; body.value = card ? card.body : '';
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal']; const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
// New appointments (#termin / #geburtstag pre-filled from the
// calendar's day-popup template) must never inherit the active
// bucket — they belong to a calendar, not a kanban column. For
// every other new card, inherit the active real bucket if we're
// in one so the user doesn't have to re-pick it.
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
// from the calendar day-popup — both count as "new", so key on
// card?.id not the card ref itself.
const isNewAppointment = !card?.id && /#(termin|geburtstag)\b/.test(body.value);
inpBucket.value = card?.bucket_id ? card.bucket_id inpBucket.value = card?.bucket_id ? card.bucket_id
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''); : (isNewAppointment ? ''
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
selectColor(card ? card.color : '#38bdf8'); selectColor(card ? card.color : '#38bdf8');
setDueDate(card?.due_date || ''); setDueDate(card?.due_date || '');
updateDoneToggle(); updateDoneToggle();
state.previewVisible = true; if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
preview.classList.remove('hidden'); showSidePanel('preview');
editorDivider?.classList.remove('hidden');
$('toggle-preview').classList.add('text-t-accent');
applyEditorSplit();
updatePreview();
modal.classList.add('open'); modal.classList.add('open');
if (linesVisible) updateGutter(); if (linesVisible) updateGutter();
currentLine = -1; currentLine = -1;
updateActiveLine(); updateActiveLine();
// Check for unsaved draft (newer than what we just loaded).
// Skipped during test runs — tests would block on the dialog.
const draft = loadDraft(card?.id);
if (draft && !window._testRunning) {
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
const draftIsNewer = draft.savedAt > cardModified;
const draftHasContent = draft.title || draft.body || draft.scratchpad;
const differsFromLoaded = draft.title !== inpTitle.value || draft.body !== body.value || draft.scratchpad !== (scratchpadBody?.value || '');
if (draftIsNewer && draftHasContent && differsFromLoaded) {
const age = Math.round((Date.now() - draft.savedAt) / 60000);
const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
const cardId = card?.id;
// Async confirm — fires after openModal returns
(async () => {
const restore = await showDialog({
title: 'unsaved draft',
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
okLabel: 'Restore',
cancelLabel: 'Discard',
});
if (!modal.classList.contains('open') || state.editingId != cardId) return;
if (restore) {
inpTitle.value = draft.title || '';
body.value = draft.body || '';
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
updatePreview();
} else {
clearDraft(cardId);
}
})();
}
} else if (draft && window._testRunning) {
clearDraft(card?.id);
}
inpTitle.focus(); inpTitle.focus();
// Plugins (collab-buckets) hook into this to heartbeat an edit lock // Plugins (collab-buckets) hook into this to heartbeat an edit lock
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } })); window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
} }
inpTitle.addEventListener('input', updatePreview); inpTitle.addEventListener('input', () => { updatePreview(); saveDraftDebounced(); });
inpTitle.addEventListener('keydown', e => { inpTitle.addEventListener('keydown', e => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); } if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
}); });
body.addEventListener('input', saveDraftDebounced);
scratchpadBody?.addEventListener('input', saveDraftDebounced);
// ── Line numbers ── // ── Line numbers ──
@@ -363,6 +449,7 @@ export function closeModal({ animate = true } = {}) {
hideTagAc(); hideTagAc();
hideSnippetAc(); hideSnippetAc();
closeFindBar(); closeFindBar();
closeAssetPicker();
modal.classList.remove('open'); modal.classList.remove('open');
state.editingId = null; state.editingId = null;
state.returnToGalleryAfterEdit = false; state.returnToGalleryAfterEdit = false;
@@ -395,13 +482,16 @@ function updatePreview() {
} }
async function doSave(close, { animate = true } = {}) { async function doSave(close, { animate = true } = {}) {
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null }; const scratch = scratchpadBody?.value || null;
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch };
if (!data.title.trim()) return; if (!data.title.trim()) return;
const returnToGallery = state.returnToGalleryAfterEdit; const returnToGallery = state.returnToGalleryAfterEdit;
const wasEditingId = state.editingId;
if (state.editingId) { if (state.editingId) {
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId)); const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null }); if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id, due_date: prev.due_date ?? null });
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data }); await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
clearDraft(state.editingId);
} else { } else {
if (state.activeGroup) { if (state.activeGroup) {
const parent = state.cards.find(c => c.id == state.activeGroup); const parent = state.cards.find(c => c.id == state.activeGroup);
@@ -418,6 +508,7 @@ async function doSave(close, { animate = true } = {}) {
state._lastSavedId = created.id; state._lastSavedId = created.id;
state.recentlyCreatedIds?.add(String(created.id)); state.recentlyCreatedIds?.add(String(created.id));
} }
clearDraft(null); // 'new' draft
} }
await window._reloadAll(); await window._reloadAll();
// Auto-sync if card has #termin or #geburtstag // Auto-sync if card has #termin or #geburtstag
@@ -535,18 +626,40 @@ function applyEditorSplit() {
preview.style.flex = `${100 - editorSplit} 1 0`; preview.style.flex = `${100 - editorSplit} 1 0`;
} }
$('toggle-preview')?.addEventListener('click', () => { function showSidePanel(which) {
state.previewVisible = !state.previewVisible; const showPreview = which === 'preview';
preview.classList.toggle('hidden', !state.previewVisible); const showScratch = which === 'scratchpad';
editorDivider?.classList.toggle('hidden', !state.previewVisible);
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible); state.previewVisible = showPreview;
if (state.previewVisible) { preview.classList.toggle('hidden', !showPreview);
scratchpadPanel?.classList.toggle('hidden', !showScratch);
editorDivider?.classList.toggle('hidden', !showPreview && !showScratch);
$('toggle-preview')?.classList.toggle('text-t-accent', showPreview);
$('toggle-scratchpad')?.classList.toggle('text-t-accent', showScratch);
if (showPreview || showScratch) {
applyEditorSplit(); applyEditorSplit();
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
} else { } else {
if (editorPane) editorPane.style.flex = '1 1 0%'; if (editorPane) editorPane.style.flex = '1 1 0%';
preview.style.flex = ''; preview.style.flex = '';
if (scratchpadPanel) scratchpadPanel.style.flex = '';
} }
updatePreview(); if (showPreview) updatePreview();
}
$('toggle-preview')?.addEventListener('click', () => {
showSidePanel(state.previewVisible ? null : 'preview');
});
$('toggle-scratchpad')?.addEventListener('click', () => {
const isOpen = !scratchpadPanel?.classList.contains('hidden');
showSidePanel(isOpen ? null : 'scratchpad');
if (!isOpen) scratchpadBody?.focus();
});
$('scratchpad-clear')?.addEventListener('click', () => {
if (scratchpadBody) scratchpadBody.value = '';
}); });
if (editorDivider) { if (editorDivider) {
@@ -1184,9 +1297,114 @@ $('md-upload-pdf-file')?.addEventListener('change', e => {
e.target.value = ''; e.target.value = '';
}); });
// ── Asset picker (insert from uploads) ──
const ASSET_IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i;
const ASSET_AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
const ASSET_PDF_EXT = /\.pdf$/i;
function assetType(name) {
if (ASSET_IMAGE_EXT.test(name)) return 'image';
if (ASSET_AUDIO_EXT.test(name)) return 'audio';
if (ASSET_PDF_EXT.test(name)) return 'pdf';
return 'other';
}
let assetPickerEl = null;
function closeAssetPicker() {
if (assetPickerEl) { assetPickerEl.remove(); assetPickerEl = null; }
}
$('md-asset-picker')?.addEventListener('click', async () => {
if (assetPickerEl) { closeAssetPicker(); return; }
let files = [];
try { files = await api('api/uploads'); } catch {}
if (!Array.isArray(files)) files = [];
const btn = $('md-asset-picker');
const rect = btn.getBoundingClientRect();
const picker = document.createElement('div');
picker.id = 'asset-picker-flyout';
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;width:340px;max-height:400px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:200;display:flex;flex-direction:column;overflow:hidden;`;
const searchHtml = `<div style="padding:8px;border-bottom:1px solid var(--t-border);">
<input id="asset-picker-search" type="text" placeholder="search assets..." autocomplete="off"
style="width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:5px 8px;font-size:11px;font-family:var(--t-font-content,'JetBrains Mono',monospace);border-radius:4px;outline:none;"
class="focus:border-t-accent">
</div>`;
const gridId = 'asset-picker-grid';
picker.innerHTML = searchHtml + `<div id="${gridId}" style="flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:6px;"></div>`;
document.body.appendChild(picker);
assetPickerEl = picker;
const grid = picker.querySelector(`#${gridId}`);
const searchInput = picker.querySelector('#asset-picker-search');
function renderTiles(filter) {
const q = (filter || '').toLowerCase();
const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q)) : files;
if (!filtered.length) {
grid.innerHTML = `<div style="grid-column:1/-1;text-align:center;padding:24px 0;color:var(--t-dim);font-size:10px;font-style:italic;">${files.length ? 'no matches' : 'no assets'}</div>`;
return;
}
grid.innerHTML = filtered.map(f => {
const type = assetType(f.name);
const label = f.name.replace(/\.[^.]+$/, '');
if (type === 'image') {
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="image" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);">
<img src="${esc(f.url)}" style="width:100%;height:100%;object-fit:cover;" loading="lazy">
</div>`;
}
const icon = type === 'audio' ? 'ph-music-note' : type === 'pdf' ? 'ph-file-pdf' : 'ph-file';
const iconColor = type === 'pdf' ? 'color:var(--t-danger)' : 'color:var(--t-dim)';
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="${type}" data-tip="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px;">
<i class="ph ${icon}" style="font-size:20px;${iconColor}"></i>
<span style="font-size:7px;color:var(--t-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--t-font-pixel,'Silkscreen',monospace);">${esc(label)}</span>
</div>`;
}).join('');
grid.querySelectorAll('.asset-pick-tile').forEach(tile => {
tile.addEventListener('click', () => {
const url = tile.dataset.url;
const name = tile.dataset.name;
const type = tile.dataset.type;
let md;
if (type === 'image') md = `![${name.replace(/\.[^.]+$/, '')}](${url})`;
else if (type === 'pdf') md = `[📄 ${name}](${url})`;
else if (type === 'audio') md = `<audio controls src="${url}"></audio>`;
else md = `[${name}](${url})`;
insertMd(md);
closeAssetPicker();
});
tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--t-accent)'; });
tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--t-border)'; });
});
}
renderTiles('');
setTimeout(() => searchInput.focus(), 0);
searchInput.addEventListener('input', () => renderTiles(searchInput.value));
searchInput.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAssetPicker(); }
});
const onClickOutside = e => {
if (!picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
closeAssetPicker();
document.removeEventListener('mousedown', onClickOutside);
}
};
document.addEventListener('mousedown', onClickOutside);
});
// ── Image resize ── // ── Image resize ──
document.addEventListener('mousedown', e => { document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const img = e.target.closest('.prose img'); const img = e.target.closest('.prose img');
if (!img || img.closest('.md-gallery-item')) return; if (!img || img.closest('.md-gallery-item')) return;
e.preventDefault(); e.preventDefault();
+3 -3
View File
@@ -170,9 +170,9 @@ export async function openGallery() {
<div class="relative"> <div class="relative">
${mediaTile(f.url, 'border-t-danger/30 opacity-60')} ${mediaTile(f.url, 'border-t-danger/30 opacity-60')}
<div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity"> <div style="position:absolute;top:4px;right:4px;display:flex;gap:4px;" class="opacity-0 group-hover:opacity-100 transition-opacity">
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" title="new card"><i class="ph ph-plus text-sm"></i></button> <button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-orphan="${f.name}" data-tip="new card"><i class="ph ph-plus text-sm"></i></button>
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" title="new slide (background)"><i class="ph ph-image-square text-sm"></i></button> <button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-accent rounded border-0 cursor-pointer hover:bg-t-accent hover:text-t-bg transition-colors" data-new-slide="${f.name}" data-tip="new slide"><i class="ph ph-image-square text-sm"></i></button>
<button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" title="delete"><i class="ph ph-trash text-sm"></i></button> <button class="w-6 h-6 flex items-center justify-center bg-black/80 text-t-danger rounded border-0 cursor-pointer hover:bg-t-danger hover:text-t-bg transition-colors" data-del-orphan="${f.name}" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
</div> </div>
</div> </div>
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div> <div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
+1 -1
View File
@@ -146,7 +146,7 @@ export function renderMd(src) {
const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => { const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase()); const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase());
if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`; if (card) return `<span class="card-link" data-card-id="${card.id}">${esc(title)}</span>`;
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`; return `<span class="card-link broken" data-tip="card not found">${esc(title)}</span>`;
}); });
let html = marked.parse(linked); let html = marked.parse(linked);
+1 -1
View File
@@ -73,7 +73,7 @@ export function render() {
<span>${esc(i.label)}</span> <span>${esc(i.label)}</span>
${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''} ${i.dismissable !== false ? `<button class="upcoming-dismiss" data-dismiss="${i.id}"><i class="ph ph-x text-xs"></i></button>` : ''}
</div>`).join('')} </div>`).join('')}
<button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" title="dismiss all"><i class="ph ph-x-circle text-sm"></i></button> <button id="dismiss-all" class="shrink-0 ml-auto text-t-dim hover:text-t-danger transition-colors" data-tip="dismiss all"><i class="ph ph-x-circle text-sm"></i></button>
</div>`; </div>`;
banner.querySelectorAll('[data-notif-id]').forEach(el => { banner.querySelectorAll('[data-notif-id]').forEach(el => {
+60 -43
View File
@@ -1,13 +1,11 @@
// ── Plugin Settings UI ── // ── Plugin Settings UI ──
// Renders the list of installed plugins with on/off toggles in the // Toggles collect pending changes client-side and commit together via
// settings overlay. Disabled plugins are skipped at PHP render time; // the Apply button. Only reloads once all changes are committed.
// toggling requires a reload to take effect (page must re-fetch the
// new script/css/html tag set).
import { $, esc, api } from './util.js'; import { $, esc, api } from './util.js';
let pluginsCache = null; let pluginsCache = null;
let dirtyAfterToggle = false; let pending = new Map(); // name -> desired enabled state
async function fetchPlugins() { async function fetchPlugins() {
const res = await fetch('api/plugins'); const res = await fetch('api/plugins');
@@ -23,63 +21,86 @@ function render(list) {
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>'; container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
return; return;
} }
container.innerHTML = list.map(p => ` container.innerHTML = list.map(p => {
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}"> const effectiveEnabled = pending.has(p.name) ? pending.get(p.name) : p.enabled;
const isPending = pending.has(p.name);
return `
<div class="flex items-start gap-3 px-3 py-2 border ${isPending ? 'border-t-accent' : 'border-t-border'} bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span> <span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span> <span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''} ${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
${isPending ? `<span class="font-pixel text-[8px] px-1 py-0.5 text-t-accent border border-t-accent uppercase">${effectiveEnabled ? 'will enable' : 'will disable'}</span>` : ''}
</div> </div>
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''} ${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
</div> </div>
<label class="flex items-center cursor-pointer shrink-0 mt-0.5"> <label class="flex items-center cursor-pointer shrink-0 mt-0.5">
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}"> <input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${effectiveEnabled ? 'checked' : ''} data-name="${esc(p.name)}" data-original="${p.enabled ? '1' : '0'}">
</label> </label>
</div> </div>`;
`).join(''); }).join('');
container.querySelectorAll('.plugin-toggle').forEach(input => { container.querySelectorAll('.plugin-toggle').forEach(input => {
input.addEventListener('change', onToggle); input.addEventListener('change', onToggle);
}); });
updateApplyBar();
} }
let reloadTimer = null; function onToggle(e) {
async function onToggle(e) {
const input = e.currentTarget; const input = e.currentTarget;
const name = input.dataset.name; const name = input.dataset.name;
const enabled = input.checked; const original = input.dataset.original === '1';
input.disabled = true; const desired = input.checked;
try { if (desired === original) {
const res = await api('api/plugins/toggle', { pending.delete(name);
method: 'POST', } else {
body: { name, enabled }, pending.set(name, desired);
});
if (res.error) throw new Error(res.error);
pluginsCache = res.plugins;
dirtyAfterToggle = true;
$('plugins-reload-notice')?.classList.remove('hidden');
// Auto-reload so the effect of the toggle is visible immediately.
// Debounced so rapid successive toggles only trigger one reload.
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => location.reload(), 400);
} catch (err) {
// Revert UI on failure
input.checked = !enabled;
console.error('[plugins] toggle failed:', err);
} finally {
input.disabled = false;
} }
render(pluginsCache); // re-render to update badge and border
}
function updateApplyBar() {
const bar = $('plugins-apply-bar');
if (!bar) return;
if (pending.size === 0) {
bar.classList.add('hidden');
return;
}
bar.classList.remove('hidden');
const count = pending.size;
const label = $('plugins-apply-count');
if (label) label.textContent = `${count} pending change${count !== 1 ? 's' : ''}`;
}
async function applyChanges() {
if (pending.size === 0) return;
const btn = $('plugins-apply-btn');
if (btn) { btn.disabled = true; btn.textContent = 'applying...'; }
try {
for (const [name, enabled] of pending) {
const res = await api('api/plugins/toggle', { method: 'POST', body: { name, enabled } });
if (res.error) throw new Error(`${name}: ${res.error}`);
}
pending.clear();
// Reload so the new plugin set takes effect
location.reload();
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Apply'; }
alert('Failed to apply: ' + err.message);
}
}
function discardChanges() {
pending.clear();
render(pluginsCache);
} }
export async function openPluginSettings() { export async function openPluginSettings() {
const container = $('plugins-list'); const container = $('plugins-list');
if (!container) return; if (!container) return;
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>'; container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
if (!dirtyAfterToggle) {
$('plugins-reload-notice')?.classList.add('hidden');
}
try { try {
const list = await fetchPlugins(); const list = await fetchPlugins();
render(list); render(list);
@@ -88,9 +109,5 @@ export async function openPluginSettings() {
} }
} }
// Wire reload button once at module load $('plugins-apply-btn')?.addEventListener('click', applyChanges);
document.addEventListener('DOMContentLoaded', () => { $('plugins-discard-btn')?.addEventListener('click', discardChanges);
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
}, { once: true });
// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
+155
View File
@@ -226,6 +226,66 @@ function hitHtml(card, idx, words) {
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`; ${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
} }
function openBucketPicker(cardId, x, y) {
document.querySelector('.search-bucket-picker')?.remove();
const picker = document.createElement('div');
picker.className = 'search-bucket-picker';
// Clamp to viewport so a click near the bottom-right doesn't flow
// off-screen — rendered invisibly first to measure, then placed.
picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`;
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;';
const addRow = (label, color, onClick) => {
const item = document.createElement('button');
item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
item.innerHTML = `<span style="width:8px;height:8px;border-radius:2px;background:${color};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(label)}</span>`;
item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; });
item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; });
item.addEventListener('click', onClick);
list.appendChild(item);
};
addRow('(unsorted)', 'transparent', async () => {
picker.remove();
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } });
await window._reloadAll();
search(input.value);
});
state.buckets.forEach(b => {
addRow(b.name, b.color || '#64748b', async () => {
picker.remove();
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } });
await window._reloadAll();
search(input.value);
});
});
picker.appendChild(list);
document.body.appendChild(picker);
// Place, then flip if we'd overflow the viewport.
const r = picker.getBoundingClientRect();
let left = x, top = y;
if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8);
if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8);
picker.style.left = left + 'px';
picker.style.top = top + 'px';
picker.style.visibility = '';
const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } };
const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } };
const cleanup = () => {
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('keydown', onEsc, true);
};
setTimeout(() => {
document.addEventListener('mousedown', onDown, true);
document.addEventListener('keydown', onEsc, true);
}, 0);
}
function bindHits() { function bindHits() {
results.querySelectorAll('.search-hit').forEach(el => { results.querySelectorAll('.search-hit').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
@@ -234,6 +294,16 @@ function bindHits() {
else { active = i; updateActive(); } else { active = i; updateActive(); }
}); });
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); }); el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
const id = el.dataset.cardId;
if (!id) return;
// Highlight the row so the user sees which card the picker
// will move — mirrors the active-on-click behaviour.
const i = parseInt(el.dataset.idx);
if (!isNaN(i)) { active = i; updateActive(); }
openBucketPicker(id, e.clientX, e.clientY);
});
// Drag: only non-group, non-child cards // Drag: only non-group, non-child cards
if (el.draggable) { if (el.draggable) {
@@ -333,3 +403,88 @@ replaceAllBtn?.addEventListener('click', async () => {
search(input.value); search(input.value);
setTimeout(() => { replaceStatus.style.color = ''; }, 3000); setTimeout(() => { replaceStatus.style.color = ''; }, 3000);
}); });
// ── Saved searches ──────────────────────────────────────────────
// localStorage-persisted list of search strings. The bookmark button
// toggles a popup that lets the user pick one (fills input + runs),
// save the current input value, or delete saved entries.
const SAVED_KEY = 'hyprcrd-saved-searches';
const savedBtn = $('search-saved-btn');
const savedPopup = $('search-saved-popup');
const savedList = $('search-saved-list');
const savedAdd = $('search-saved-add');
function loadSaved() {
try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '[]'); } catch { return []; }
}
function storeSaved(arr) {
localStorage.setItem(SAVED_KEY, JSON.stringify(arr));
}
function renderSavedList() {
if (!savedList) return;
const items = loadSaved();
if (!items.length) {
savedList.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel text-center">no saved searches</div>';
return;
}
savedList.innerHTML = items.map((q, i) => `
<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-t-raised cursor-pointer transition-colors search-saved-row" data-idx="${i}">
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
<span class="flex-1 text-xs text-t-muted truncate">${esc(q)}</span>
<button class="search-saved-del text-t-dim hover:text-t-danger text-xs px-1" data-idx="${i}" title="remove"><i class="ph ph-x"></i></button>
</div>
`).join('');
savedList.querySelectorAll('.search-saved-row').forEach(row => {
row.addEventListener('click', (e) => {
if (e.target.closest('.search-saved-del')) return;
const q = items[parseInt(row.dataset.idx)];
if (q !== undefined) {
input.value = q;
search(q);
closeSavedPopup();
}
});
});
savedList.querySelectorAll('.search-saved-del').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
const arr = loadSaved();
arr.splice(idx, 1);
storeSaved(arr);
renderSavedList();
});
});
}
function openSavedPopup() {
renderSavedList();
savedPopup?.classList.remove('hidden');
const close = (e) => {
if (savedPopup?.contains(e.target) || e.target === savedBtn) return;
closeSavedPopup();
document.removeEventListener('mousedown', close, true);
};
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
}
function closeSavedPopup() {
savedPopup?.classList.add('hidden');
}
savedBtn?.addEventListener('click', (e) => {
e.stopPropagation();
if (savedPopup?.classList.contains('hidden')) openSavedPopup();
else closeSavedPopup();
});
savedAdd?.addEventListener('click', () => {
const q = input.value.trim();
if (!q) return;
const arr = loadSaved();
if (arr.includes(q)) return; // no dupes
arr.unshift(q);
storeSaved(arr);
renderSavedList();
});
+5
View File
@@ -6,6 +6,11 @@ export const state = {
// Data // Data
cards: [], cards: [],
buckets: [], buckets: [],
// True once we've got a real response from api/cards (or the
// cached-cards hydration). Until then the board shows a spinner
// instead of the "no cards found" placeholder — an empty board on
// first load is an honest "still fetching" state, not a real empty.
cardsLoaded: false,
// Filters & view — activeBucket is persisted across reloads via // Filters & view — activeBucket is persisted across reloads via
// localStorage. Numeric ids are stringified; the special filters // localStorage. Numeric ids are stringified; the special filters
+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-bar-height', d.barHeight + 'px');
set('--tp-pad-card', d.padCard + 'px'); set('--tp-pad-card', d.padCard + 'px');
set('--tp-shadow-card', shadows[d.shadowCard] || 'none'); set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
const scan = $('theme-preview-scan');
if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : 'none';
} }
function fillFontSelect(id, value) { function fillFontSelect(id, value) {
@@ -129,8 +127,6 @@ function syncControlsFromPending() {
const valEl = $(valId); const valEl = $(valId);
if (valEl) valEl.textContent = v; if (valEl) valEl.textContent = v;
} }
const scanEl = $('setting-scanlines');
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
} }
function commitDesign() { function commitDesign() {
@@ -167,16 +163,6 @@ function initDesignSettings() {
if (designWired) return; if (designWired) return;
designWired = true; designWired = true;
// Scanlines toggle
const scanEl = $('setting-scanlines');
const scanRow = scanEl?.closest('.toggle-row');
if (scanRow) scanRow.onclick = () => {
const nowOn = !scanEl.classList.contains('on');
scanEl.classList.toggle('on', nowOn);
designPending.scanlines = nowOn ? '1' : '0';
commitDesign();
};
// Selects // Selects
for (const [id, key] of SELECT_FIELDS) { for (const [id, key] of SELECT_FIELDS) {
const el = $(id); const el = $(id);
+120
View File
@@ -0,0 +1,120 @@
// ── Global tooltip system ──
// Replaces native title="" with styled tooltips. Supports:
// data-tip="short text" — single-line tooltip (replaces title)
// data-help="line1\nline2" — multi-line inline help (shown on hover)
const SHOW_DELAY = 400;
const HIDE_DELAY = 100;
const MAX_LIFE = 5000; // watchdog: tooltip never outlives this
const OFFSET = 8;
let tipEl = null;
let showTimer = null;
let hideTimer = null;
let lifeTimer = null;
let currentTarget = null;
function create() {
if (tipEl) return;
tipEl = document.createElement('div');
tipEl.id = 'tooltip';
tipEl.setAttribute('role', 'tooltip');
document.body.appendChild(tipEl);
}
function show(target) {
clearTimeout(hideTimer);
if (currentTarget === target) return;
currentTarget = target;
create();
const tip = target.dataset.tip || '';
const help = target.dataset.help || '';
if (!tip && !help) { hide(); return; }
if (help) {
tipEl.classList.add('tooltip-help');
tipEl.innerHTML = help.replace(/\\n/g, '<br>');
} else {
tipEl.classList.remove('tooltip-help');
tipEl.textContent = tip;
}
tipEl.style.visibility = 'hidden';
tipEl.style.display = 'block';
const rect = target.getBoundingClientRect();
const tipRect = tipEl.getBoundingClientRect();
let top, left;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow >= tipRect.height + OFFSET || spaceBelow >= spaceAbove) {
top = rect.bottom + OFFSET;
tipEl.classList.remove('tooltip-above');
} else {
top = rect.top - tipRect.height - OFFSET;
tipEl.classList.add('tooltip-above');
}
left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
top = Math.max(4, top);
tipEl.style.top = top + 'px';
tipEl.style.left = left + 'px';
tipEl.style.visibility = '';
// Watchdog: if pointerleave never fires (fast flick off the
// element, target removed from the DOM mid-hover, focus taken by
// another window), the tooltip would sit forever. Force-hide
// after MAX_LIFE regardless.
clearTimeout(lifeTimer);
lifeTimer = setTimeout(hide, MAX_LIFE);
}
function hide() {
currentTarget = null;
clearTimeout(lifeTimer);
if (tipEl) tipEl.style.display = 'none';
}
function onEnter(e) {
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
const target = el?.closest?.('[data-tip],[data-help]');
if (!target) return;
clearTimeout(hideTimer);
showTimer = setTimeout(() => show(target), SHOW_DELAY);
}
function onLeave(e) {
const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement;
const target = el?.closest?.('[data-tip],[data-help]');
if (!target) return;
clearTimeout(showTimer);
hideTimer = setTimeout(hide, HIDE_DELAY);
}
document.addEventListener('pointerenter', onEnter, true);
document.addEventListener('pointerleave', onLeave, true);
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
document.addEventListener('scroll', () => { clearTimeout(showTimer); hide(); }, true);
// Belt-and-suspenders sanity check on every move: if the pointer is
// no longer over the tip-owning target (or the target has been
// detached from the DOM since show time), hide. Catches fast flicks
// that skip the expected pointerleave and DOM churn that yanks the
// element out from under us.
document.addEventListener('pointermove', (e) => {
if (!currentTarget) return;
if (!currentTarget.isConnected) { hide(); return; }
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || !currentTarget.contains(el)) hide();
}, true);
// Window losing focus / tab hidden — nothing to hover over while
// we're not there, kill any lingering tip.
window.addEventListener('blur', () => { clearTimeout(showTimer); hide(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) { clearTimeout(showTimer); hide(); } });
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* Bookmarks Plugin: backend.
*
* Single endpoint that fetches a URL's <title> and favicon so the
* frontend doesn't have to deal with CORS. Light and stateless —
* the bookmarks themselves live in the card body (JSON blob).
*
* Routes:
* GET /api/bookmarks/fetch-meta?url=... → { title, favicon }
*/
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return;
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
http_response_code(400);
echo json_encode(['error' => 'valid url required']);
exit;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
CURLOPT_SSL_VERIFYPEER => true,
]);
$html = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
curl_close($ch);
$title = '';
if ($html && $status >= 200 && $status < 400) {
// Extract <title>
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
// og:title fallback
if ($title === '' && preg_match('/<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) {
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
}
// Favicon: resolve the URL, then download the bytes and return a
// self-contained data: URI so the card body is fully offline-capable
// and opening the overlay doesn't leak requests to every bookmarked
// site. Caps at 32 KB — anything bigger is probably not an icon.
$parsed = parse_url($finalUrl);
$origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
$faviconUrl = $origin . '/favicon.ico';
if ($html && preg_match('/<link[^>]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) {
$href = $m[1];
if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href;
elseif (str_starts_with($href, '/')) $href = $origin . $href;
elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href;
$faviconUrl = $href;
}
$faviconData = '';
$ch2 = curl_init($faviconUrl);
curl_setopt_array($ch2, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
]);
$favBytes = curl_exec($ch2);
$favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
$favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
curl_close($ch2);
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
$mime = preg_replace('/;.*$/', '', $favMime);
$faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes);
}
header('Content-Type: application/json');
echo json_encode([
'ok' => true,
'url' => $finalUrl,
'title' => mb_substr($title, 0, 200),
'favicon' => $faviconData,
]);
exit;
});
+260
View File
@@ -0,0 +1,260 @@
/* ── Bookmarks plugin ── */
/* Board card override: the MutationObserver replaces the card's
innerHTML with a coloured tile (card.color as bg) showing a
centered link icon + the title. No body preview, no tags, no
metadata — just a clean, recognisable tile. */
.board-card.bm-card {
border: none !important;
cursor: pointer;
overflow: hidden;
}
.bm-card-inner {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100px;
}
.bm-card-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px 14px 8px;
}
.bm-card-icon {
font-size: 42px;
color: rgba(0,0,0,0.2);
}
.bm-card-title {
font-family: var(--t-font-pixel);
font-size: 11px;
color: rgba(0,0,0,0.55);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bm-card-meta {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-family: var(--t-font-pixel);
font-size: 9px;
color: rgba(255,255,255,0.55);
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.bm-card-meta i { font-size: 10px; margin-right: 2px; }
/* Overlay shell */
.bookmarks-shell {
position: absolute;
inset: 40px;
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-modal, 4px);
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.bookmarks-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--t-border);
background: var(--t-bg);
flex-shrink: 0;
}
.bm-btn {
background: none;
border: 1px solid var(--t-border);
color: var(--t-muted);
padding: 4px 8px;
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.bm-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
/* Column width slider — full width */
.bookmarks-slider-row {
padding: 6px 16px;
border-bottom: 1px solid var(--t-border);
background: var(--t-bg);
flex-shrink: 0;
}
.bookmarks-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--t-border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.bookmarks-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: var(--t-accent); cursor: pointer; border: none;
}
.bookmarks-slider::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: var(--t-accent); cursor: pointer; border: none;
}
/* Column container — horizontal scroll, wheel redirected by JS */
.bookmarks-columns {
flex: 1;
display: flex;
gap: 12px;
padding: 14px 16px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.bookmarks-columns::-webkit-scrollbar { display: none; }
/* Single column */
.bm-col {
flex: 0 0 var(--bm-col-w, 220px);
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.bm-col-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-bottom: 1px solid var(--t-border);
flex-shrink: 0;
}
.bm-col-header .bm-col-name {
font-family: var(--t-font-pixel);
font-size: 10px;
color: var(--t-bright);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.12s;
}
.bm-col-header .bm-col-name:hover { color: var(--t-accent); }
.bm-col-header .bm-col-add {
background: none;
border: none;
color: var(--t-dim);
cursor: pointer;
font-size: 12px;
padding: 2px;
transition: color 0.12s;
}
.bm-col-header .bm-col-add:hover { color: var(--t-accent); }
.bm-col-items {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Bookmark item */
.bm-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: var(--t-raised, var(--t-surface));
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-card, 3px);
cursor: pointer;
user-select: none;
transition: border-color 0.12s, background 0.12s;
min-width: 0;
}
.bm-item:hover {
border-color: var(--t-accent);
background: color-mix(in srgb, var(--t-accent) 6%, var(--t-surface));
}
.bm-item .bm-favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2px;
object-fit: contain;
}
.bm-item .bm-favicon-placeholder {
width: 16px;
height: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--t-dim);
font-size: 12px;
}
.bm-item .bm-item-title {
flex: 1;
font-size: 11px;
color: var(--t-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bm-item:hover .bm-item-title { color: var(--t-bright); }
.bm-item.dragging { opacity: 0.4; }
.bm-item.bm-drop-above {
box-shadow: 0 -2px 0 0 var(--t-accent);
}
.bm-item.bm-drop-below {
box-shadow: 0 2px 0 0 var(--t-accent);
}
/* Context menu */
.bm-ctx {
position: fixed;
z-index: 300;
background: var(--t-surface);
border: 1px solid var(--t-border);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
border-radius: var(--ui-radius-modal, 4px);
min-width: 140px;
padding: 4px 0;
}
.bm-ctx button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
color: var(--t-muted);
font-size: 11px;
cursor: pointer;
text-align: left;
transition: background 0.08s, color 0.08s;
}
.bm-ctx button:hover { background: var(--t-raised); color: var(--t-bright); }
.bm-ctx button.danger:hover { color: var(--t-danger); }
/* Empty column state */
.bm-col-empty {
padding: 16px 8px;
text-align: center;
font-size: 10px;
color: var(--t-dim);
font-family: var(--t-font-pixel);
}
+19
View File
@@ -0,0 +1,19 @@
<!-- Bookmarks overlay. Opens when the user clicks a #bookmarks card
on the board (instead of the normal detail view). Column-based
layout; each column is a topic, each item is a bookmark with
favicon + title. -->
<div id="bookmarks-overlay" class="overlay fixed inset-0 z-[80]" style="background:var(--t-overlay)">
<div class="bookmarks-shell">
<div class="bookmarks-header">
<i class="ph ph-link text-t-accent text-base"></i>
<span id="bookmarks-title" class="font-pixel text-sm text-t-accent flex-1 truncate cursor-pointer hover:text-t-bright transition-colors" data-tip="click to rename"></span>
<button id="bookmarks-add-btn" class="bm-btn" data-tip="new bookmark (paste URL)"><i class="ph ph-plus text-sm"></i></button>
<button id="bookmarks-col-add" class="bm-btn" data-tip="new column"><i class="ph ph-columns-plus-right text-sm"></i></button>
<button id="bookmarks-close" class="bm-btn hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="bookmarks-slider-row">
<input type="range" id="bookmarks-col-width" min="140" max="400" step="10" value="220" class="bookmarks-slider">
</div>
<div id="bookmarks-columns" class="bookmarks-columns"></div>
</div>
</div>
+614
View File
@@ -0,0 +1,614 @@
// ── Bookmarks Plugin ──
//
// A bookmark list is a card tagged #bookmarks. Its body is a JSON
// blob: { type:"bookmarks", columns:[{name, items:[{url,title,favicon}]}], columnWidth }.
// On the board the card renders as a coloured tile with a link icon
// and no body preview. Clicking opens a column-based overlay; each
// item is a small box with favicon + title. Click → open URL.
//
// Phase 1+2: skeleton, board decoration, overlay (read + write),
// add/delete/rename, clipboard paste + server-side meta fetch.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[bookmarks] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const TAG = 'bookmarks';
let activeCardId = null; // card id currently open in the overlay
let data = null; // parsed JSON body of the active card
// ── Helpers ──────────────────────────────────────────────────────
function isBookmarkCard(card) {
return (card.tags || '').split(',').map(s => s.trim()).includes(TAG);
}
function emptyData() {
return { type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 };
}
function parseBody(body) {
const text = (body || '').trim();
// Full string might be pure JSON (legacy) or JSON + trailing
// "\n\n#bookmarks" tag line that extractTags needs on the server.
try {
const d = JSON.parse(text);
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
} catch {}
// Strip trailing tag line and retry.
const jsonEnd = text.lastIndexOf('}');
if (jsonEnd >= 0) {
try {
const d = JSON.parse(text.substring(0, jsonEnd + 1));
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
} catch {}
}
return emptyData();
}
function isUrl(s) {
return /^https?:\/\/.+/i.test((s || '').trim());
}
function isCardLink(s) {
return /^card:\d+$/i.test((s || '').trim());
}
function parseCardId(s) {
const m = /^card:(\d+)$/i.exec((s || '').trim());
return m ? m[1] : null;
}
async function save() {
if (!activeCardId || !data) return;
// The trailing #bookmarks tag is essential: the server's
// extractTags() parses hashtags from the body text to populate
// the `tags` column. Without it, isBookmarkCard (which checks
// tags, not body) wouldn't recognise the card on meta-only
// loads where body is still null.
const body = JSON.stringify(data) + '\n\n#bookmarks';
await hyprcrd.api(`api/cards/${activeCardId}`, {
method: 'PATCH',
body: { body },
});
}
// ── Board card decoration ────────────────────────────────────────
// MutationObserver on #board: any card whose tags include "bookmarks"
// gets the .bm-card class + a link icon. Click is intercepted to
// open the overlay instead of the detail view.
function decorateBoard() {
const board = $('board');
if (!board) return;
const cards = window.state?.cards || [];
board.querySelectorAll('.board-card[data-id]').forEach(el => {
const card = cards.find(c => String(c.id) === el.dataset.id);
if (!card || !isBookmarkCard(card)) return;
if (el.dataset.bmDone) return;
el.dataset.bmDone = '1';
el.classList.add('bm-card');
// Replace the entire card content with a coloured tile
// showing just a big link icon and the title. The original
// innerHTML (bar + body preview + tags + meta) is noise for
// a bookmark list card.
const color = card.color || '#38bdf8';
el.style.background = `color-mix(in srgb, ${color} 70%, var(--t-surface))`;
el.style.borderColor = color;
const date = (card.updated_at || card.created_at || '').substring(0, 10);
const bucket = card.bucket_name || '';
el.innerHTML = `
<div class="bm-card-inner">
<div class="bm-card-center">
<i class="ph ph-link bm-card-icon"></i>
<span class="bm-card-title">${esc(card.title)}</span>
</div>
<div class="bm-card-meta">
<span><i class="ph ph-clock"></i>${esc(date)}</span>
${bucket ? `<span><i class="ph ph-folder"></i>${esc(bucket)}</span>` : ''}
</div>
</div>
`;
el.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
openOverlay(card.id);
}, true);
el.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
openOverlay(card.id);
}, true);
});
}
// Re-decorate after every board render.
new MutationObserver(decorateBoard).observe(
$('board') || document.body,
{ childList: true, subtree: true }
);
// ── Overlay ──────────────────────────────────────────────────────
const overlay = $('bookmarks-overlay');
const colsEl = $('bookmarks-columns');
const slider = $('bookmarks-col-width');
async function openOverlay(cardId) {
activeCardId = cardId;
let card = (window.state?.cards || []).find(c => c.id == cardId);
if (!card) return;
// Body might still be null if Phase-2 hasn't loaded it yet.
// Fetch it explicitly so we don't clobber the stored JSON
// (including columnWidth) with emptyData() defaults.
if (card.body == null) {
const full = await hyprcrd.api('api/cards?ids=' + cardId);
if (Array.isArray(full) && full[0]) {
const idx = (window.state?.cards || []).findIndex(c => c.id == cardId);
if (idx >= 0) window.state.cards[idx] = full[0];
card = full[0];
}
}
$('bookmarks-title').textContent = card.title || 'Bookmarks';
data = parseBody(card.body);
if (slider) slider.value = data.columnWidth || 220;
applyColumnWidth();
renderColumns();
overlay?.classList.add('open');
}
function closeOverlay() {
overlay?.classList.remove('open');
activeCardId = null;
data = null;
hideCtx();
}
function applyColumnWidth() {
const w = slider ? parseInt(slider.value) : 220;
colsEl?.style.setProperty('--bm-col-w', w + 'px');
if (data) data.columnWidth = w;
}
function renderColumns() {
if (!colsEl || !data) return;
colsEl.innerHTML = '';
data.columns.forEach((col, ci) => {
const div = document.createElement('div');
div.className = 'bm-col';
div.dataset.col = ci;
// Header
const hdr = document.createElement('div');
hdr.className = 'bm-col-header';
hdr.innerHTML = `
<span class="bm-col-name" title="${esc(col.name)}">${esc(col.name)}</span>
<button class="bm-col-add" data-col="${ci}" title="add bookmark"><i class="ph ph-plus"></i></button>
`;
hdr.querySelector('.bm-col-name').addEventListener('click', () => renameColumn(ci));
hdr.querySelector('.bm-col-name').addEventListener('contextmenu', (e) => {
e.preventDefault();
showColCtx(ci, e.clientX, e.clientY);
});
hdr.querySelector('.bm-col-add').addEventListener('click', () => addBookmark(ci));
div.appendChild(hdr);
// Items
const items = document.createElement('div');
items.className = 'bm-col-items';
items.dataset.col = ci;
if (!col.items.length) {
items.innerHTML = '<div class="bm-col-empty">no bookmarks</div>';
} else {
col.items.forEach((it, ii) => {
const el = document.createElement('div');
el.className = 'bm-item';
el.dataset.col = ci;
el.dataset.idx = ii;
el.draggable = true;
const cardId = parseCardId(it.url);
const isCard = !!cardId;
let fav;
if (isCard) {
// Card link: show a card icon instead of a web favicon.
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
const linkedColor = linked?.color || 'var(--t-accent)';
fav = `<i class="ph ph-card bm-favicon-placeholder" style="color:${esc(linkedColor)}"></i>`;
} else {
fav = it.favicon
? `<img class="bm-favicon" src="${esc(it.favicon)}" onerror="this.outerHTML='<i class=\\'ph ph-globe bm-favicon-placeholder\\'></i>'">`
: '<i class="ph ph-globe bm-favicon-placeholder"></i>';
}
el.innerHTML = `${fav}<span class="bm-item-title" title="${esc(it.title || it.url)}">${esc(it.title || it.url)}</span>`;
el.addEventListener('click', () => {
if (isCard) {
// Check if the linked card is itself a bookmark list.
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
if (linked && isBookmarkCard(linked)) {
openOverlay(linked.id);
} else {
closeOverlay();
window._openDetail?.(parseInt(cardId));
}
return;
}
if (it.url) window.open(it.url, '_blank', 'noopener');
});
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
showItemCtx(ci, ii, e.clientX, e.clientY);
});
// Drag
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('application/x-bm', JSON.stringify({ col: ci, idx: ii }));
e.dataTransfer.effectAllowed = 'move';
el.classList.add('dragging');
});
el.addEventListener('dragend', () => el.classList.remove('dragging'));
items.appendChild(el);
});
}
// Drop target — positional: a drop indicator line appears
// between items based on the cursor's vertical position.
// The item is inserted AT that position, not always at the end.
items.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('application/x-bm')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Show a drop indicator between items.
clearDropIndicator(items);
const after = getDropIndex(items, e.clientY);
const children = [...items.querySelectorAll('.bm-item')];
if (after < children.length) {
children[after].classList.add('bm-drop-above');
} else if (children.length) {
children[children.length - 1].classList.add('bm-drop-below');
}
});
items.addEventListener('dragleave', () => clearDropIndicator(items));
items.addEventListener('drop', (e) => {
clearDropIndicator(items);
const raw = e.dataTransfer.getData('application/x-bm');
if (!raw) return;
e.preventDefault();
const src = JSON.parse(raw);
const destCol = ci;
if (!data) return;
const [item] = data.columns[src.col].items.splice(src.idx, 1);
if (!item) return;
// Compute insertion index from cursor position.
const insertAt = getDropIndex(items, e.clientY);
// If source and dest are the same column, the splice above
// shifted indices — getDropIndex already sees the shortened
// list, so insertAt is correct as-is.
data.columns[destCol].items.splice(insertAt, 0, item);
save();
renderColumns();
});
div.appendChild(items);
colsEl.appendChild(div);
});
}
// ── Drag-and-drop helpers ─────────────────────────────────────────
function getDropIndex(container, clientY) {
const children = [...container.querySelectorAll('.bm-item')];
for (let i = 0; i < children.length; i++) {
const r = children[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) return i;
}
return children.length;
}
function clearDropIndicator(container) {
container.querySelectorAll('.bm-drop-above,.bm-drop-below').forEach(el => {
el.classList.remove('bm-drop-above', 'bm-drop-below');
});
}
// ── Wheel → horizontal scroll ────────────────────────────────────
colsEl?.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (colsEl.scrollWidth <= colsEl.clientWidth) return;
e.preventDefault();
colsEl.scrollLeft += e.deltaY;
}, { passive: false });
// ── Slider ───────────────────────────────────────────────────────
slider?.addEventListener('input', () => {
applyColumnWidth();
renderColumns();
});
slider?.addEventListener('change', () => save());
// ── Context menus ────────────────────────────────────────────────
let ctxEl = null;
function hideCtx() {
ctxEl?.remove();
ctxEl = null;
}
function showCtx(x, y, buttons) {
hideCtx();
ctxEl = document.createElement('div');
ctxEl.className = 'bm-ctx';
ctxEl.style.left = x + 'px';
ctxEl.style.top = y + 'px';
buttons.forEach(([label, icon, cls, fn]) => {
const b = document.createElement('button');
if (cls) b.className = cls;
b.innerHTML = `<i class="ph ${icon} text-sm"></i>${esc(label)}`;
b.addEventListener('click', () => { hideCtx(); fn(); });
ctxEl.appendChild(b);
});
document.body.appendChild(ctxEl);
// Clamp to viewport
const r = ctxEl.getBoundingClientRect();
if (r.right > window.innerWidth - 8) ctxEl.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight - 8) ctxEl.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
setTimeout(() => {
const off = (e) => { if (!ctxEl?.contains(e.target)) { hideCtx(); document.removeEventListener('mousedown', off, true); } };
document.addEventListener('mousedown', off, true);
}, 0);
}
function showItemCtx(col, idx, x, y) {
const item = data?.columns?.[col]?.items?.[idx];
if (!item) return;
showCtx(x, y, [
['Rename', 'ph-pencil-simple', '', async () => {
const name = await hyprcrd.dialog.show({ title: 'rename bookmark', input: true, defaultValue: item.title || '', okLabel: 'Save' });
if (name === false || name === null) return;
item.title = name;
await save();
renderColumns();
}],
['Delete', 'ph-trash', 'danger', async () => {
data.columns[col].items.splice(idx, 1);
await save();
renderColumns();
}],
]);
}
async function renameColumn(col) {
const column = data?.columns?.[col];
if (!column) return;
const name = await hyprcrd.dialog.show({ title: 'rename column', input: true, defaultValue: column.name, okLabel: 'Save' });
if (name === false || name === null || name.trim() === '') return;
column.name = name.trim();
await save();
renderColumns();
}
function showColCtx(col, x, y) {
const column = data?.columns?.[col];
if (!column) return;
showCtx(x, y, [
['Rename column', 'ph-pencil-simple', '', () => renameColumn(col)],
['Delete column', 'ph-trash', 'danger', async () => {
if (column.items.length) {
const ok = await hyprcrd.dialog.show({ title: 'delete column', message: `"${column.name}" has ${column.items.length} bookmark(s). Delete anyway?`, danger: true, okLabel: 'Delete' });
if (!ok) return;
}
data.columns.splice(col, 1);
if (!data.columns.length) data.columns.push({ name: 'General', items: [] });
await save();
renderColumns();
}],
]);
}
// ── Add bookmark (clipboard + meta-fetch) ────────────────────────
async function addBookmark(colIdx) {
let url = '';
try {
const clip = await navigator.clipboard.readText();
if (isUrl(clip) || isCardLink(clip)) url = clip.trim();
} catch {}
if (!url) {
const entered = await hyprcrd.dialog.show({
title: 'new bookmark',
input: true,
placeholder: 'https://... or card:123',
okLabel: 'Add',
});
if (!entered || !entered.trim()) return;
url = entered.trim();
}
const item = { url, title: url, favicon: '' };
if (isCardLink(url)) {
// Resolve card title for the display label.
const cid = parseCardId(url);
const card = (window.state?.cards || []).find(c => String(c.id) === cid);
if (card) item.title = card.title || url;
} else if (isUrl(url)) {
try {
const meta = await hyprcrd.api('api/bookmarks/fetch-meta?url=' + encodeURIComponent(url));
if (meta?.ok) {
if (meta.title) item.title = meta.title;
if (meta.favicon) item.favicon = meta.favicon;
if (meta.url) item.url = meta.url;
}
} catch {}
}
data.columns[colIdx].items.push(item);
await save();
renderColumns();
}
// ── Add column ───────────────────────────────────────────────────
async function addColumn() {
const name = await hyprcrd.dialog.show({
title: 'new column',
input: true,
placeholder: 'column name',
okLabel: 'Create',
});
if (!name || !name.trim()) return;
data.columns.push({ name: name.trim(), items: [] });
await save();
renderColumns();
// Scroll to the new column
setTimeout(() => { colsEl.scrollLeft = colsEl.scrollWidth; }, 50);
}
// ── Header button injection ──────────────────────────────────────
// Creates a new #bookmarks card (or opens the most recent one).
function injectHeaderButton() {
if ($('bookmarks-btn')) return;
const header = document.querySelector('header#main-header');
if (!header) return;
const rows = header.querySelectorAll('.flex.items-center.gap-3');
const group = rows[rows.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'bookmarks-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-link text-sm"></i><span class="font-pixel text-[10px]">Bookmarks</span>';
btn.addEventListener('click', () => {
const cards = (window.state?.cards || []).filter(isBookmarkCard)
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
if (cards.length === 0) {
createBookmarkCard();
} else {
// Always show picker so the user can create new lists
// even when only one exists.
showBookmarkPicker(btn, cards);
}
});
group.insertBefore(btn, group.firstChild);
}
// ── Rename the currently open bookmark card ──────────────────
async function renameCard() {
if (!activeCardId) return;
const card = (window.state?.cards || []).find(c => c.id == activeCardId);
const current = card?.title || '';
const name = await hyprcrd.dialog.show({
title: 'rename bookmark list',
input: true,
defaultValue: current,
okLabel: 'Save',
});
if (name === false || name === null || !name.trim()) return;
await hyprcrd.api(`api/cards/${activeCardId}`, {
method: 'PATCH',
body: { title: name.trim() },
});
$('bookmarks-title').textContent = name.trim();
await window._reloadAll?.();
}
// ── Create bookmark card with name prompt ────────────────────
async function createBookmarkCard() {
const name = await hyprcrd.dialog.show({
title: 'new bookmark list',
input: true,
placeholder: 'list name...',
okLabel: 'Create',
});
if (!name || !name.trim()) return;
const active = window.state?.activeBucket;
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
const bucketId = active && !virtualBuckets.includes(active) ? active : null;
const created = await hyprcrd.api('api/cards', {
method: 'POST',
body: {
title: name.trim(),
body: JSON.stringify(emptyData()) + '\n\n#bookmarks',
color: '#38bdf8',
bucket_id: bucketId,
},
});
if (created?.id) {
await window._reloadAll?.();
openOverlay(created.id);
}
}
// ── Picker popup (multiple bookmark cards) ───────────────────
function showBookmarkPicker(anchor, cards) {
document.querySelector('.bm-picker')?.remove();
const rect = anchor.getBoundingClientRect();
const pop = document.createElement('div');
pop.className = 'bm-picker';
pop.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.bottom + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:200px;max-height:300px;display:flex;flex-direction:column;border-radius:var(--ui-radius-modal,4px);`;
const list = document.createElement('div');
list.style.cssText = 'overflow-y:auto;flex:1;';
cards.forEach(c => {
const row = document.createElement('button');
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
row.innerHTML = `<span style="width:10px;height:10px;border-radius:2px;background:${c.color || '#38bdf8'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(c.title)}</span>`;
row.addEventListener('mouseenter', () => { row.style.color = 'var(--t-bright)'; });
row.addEventListener('mouseleave', () => { row.style.color = 'var(--t-muted)'; });
row.addEventListener('click', () => { pop.remove(); openOverlay(c.id); });
list.appendChild(row);
});
pop.appendChild(list);
const addBtn = document.createElement('button');
addBtn.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px;background:none;border:none;border-top:1px solid var(--t-border);color:var(--t-accent);font-size:10px;font-family:var(--t-font-pixel);cursor:pointer;text-align:left;';
addBtn.innerHTML = '<i class="ph ph-plus text-xs"></i> new list';
addBtn.addEventListener('click', () => { pop.remove(); createBookmarkCard(); });
pop.appendChild(addBtn);
document.body.appendChild(pop);
// Clamp
const r = pop.getBoundingClientRect();
if (r.right > window.innerWidth - 8) pop.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
if (r.bottom > window.innerHeight - 8) pop.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
const close = (e) => { if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener('mousedown', close, true); } };
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
}
// ── Init ─────────────────────────────────────────────────────────
function init() {
injectHeaderButton();
$('bookmarks-close')?.addEventListener('click', closeOverlay);
$('bookmarks-col-add')?.addEventListener('click', addColumn);
$('bookmarks-add-btn')?.addEventListener('click', () => {
if (data?.columns?.length) addBookmark(0);
});
$('bookmarks-title')?.addEventListener('click', renameCard);
overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay?.classList.contains('open')) {
e.stopPropagation();
closeOverlay();
}
}, true);
decorateBoard();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'bookmarks',
version: '1.0.0',
description: 'Column-based bookmark manager stored as cards',
});
+11
View File
@@ -0,0 +1,11 @@
{
"name": "bookmarks",
"version": "1.0.0",
"description": "Bookmark manager stored as cards. Column-based view with favicon fetch, clipboard paste, and drag-and-drop.",
"frontend": {
"js": ["bookmarks.js"],
"css": ["bookmarks.css"],
"html": ["bookmarks.html"]
},
"backend": "backend.php"
}
+9 -9
View File
@@ -3,13 +3,13 @@
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;"> <div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar"> <div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button> <button data-tool="brush" class="toolbar-btn active" data-tip="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button> <button data-tool="eraser" class="toolbar-btn" data-tip="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button> <button data-tool="line" class="toolbar-btn" data-tip="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button> <button data-tool="rect" class="toolbar-btn" data-tip="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button> <button data-tool="circle" class="toolbar-btn" data-tip="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button> <button data-tool="fill" class="toolbar-btn" data-tip="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button> <button data-tool="text" class="toolbar-btn" data-tip="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<label class="flex items-center gap-1 text-t-muted text-xs"> <label class="flex items-center gap-1 text-t-muted text-xs">
<span>Char:</span> <span>Char:</span>
@@ -24,9 +24,9 @@
<span>H:</span> <span>H:</span>
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0"> <input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
</label> </label>
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button> <button id="ascii-canvas-resize" class="toolbar-btn text-xs" data-tip="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button> <button id="ascii-canvas-clear" class="toolbar-btn text-xs" data-tip="Clear"><i class="ph ph-trash text-sm"></i></button>
<div class="flex-1"></div> <div class="flex-1"></div>
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span> <span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
</div> </div>
+383 -23
View File
@@ -43,6 +43,46 @@
if (!in_array('seen', $colNames)) { if (!in_array('seen', $colNames)) {
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0"); $db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
} }
// Group chat: `room_uid` set → message belongs to a room (group
// thread), and `peer` holds the author's username (always — 1:1
// messages use peer for the other party, group messages reuse the
// column for the sender). NULL room_uid = classic 1:1.
if (!in_array('room_uid', $colNames)) {
$db->exec("ALTER TABLE chat_messages ADD COLUMN room_uid TEXT");
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)");
}
// Chat rooms + membership, mirrored from the relay on poll. The
// client is the read-through cache; relay remains the source of
// truth so a second device can join a room just by polling.
$db->exec("
CREATE TABLE IF NOT EXISTS chat_rooms (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_username TEXT NOT NULL,
created_at TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
");
$db->exec("
CREATE TABLE IF NOT EXISTS chat_room_members (
room_uid TEXT NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY (room_uid, username)
)
");
// Per-thread settings. Currently: `disposable` flag for 1:1 threads
// — when true, the relay drops the messages after the 7-day window
// instead of keeping them in the long-term store. Rooms are always
// persistent (enforced relay-side), so this only applies to peers.
$db->exec("
CREATE TABLE IF NOT EXISTS chat_thread_settings (
peer TEXT PRIMARY KEY,
disposable INTEGER NOT NULL DEFAULT 0
)
");
// Track the largest relay msg id we've ever seen (so the next poll uses since=that) // Track the largest relay msg id we've ever seen (so the next poll uses since=that)
$db->exec(" $db->exec("
CREATE TABLE IF NOT EXISTS chat_state ( CREATE TABLE IF NOT EXISTS chat_state (
@@ -106,49 +146,102 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route === '/api/chat/conversations' && $method === 'GET') { if ($route === '/api/chat/conversations' && $method === 'GET') {
$db = Database::connect(); $db = Database::connect();
// Latest message per peer + unread count, single query // 1:1 threads — one row per peer (room_uid IS NULL).
$rows = $db->query(" $oneOnOne = $db->query("
SELECT SELECT
peer, 'peer' AS kind,
peer AS key,
peer AS label,
MAX(sent_at) AS last_at, MAX(sent_at) AS last_at,
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_body, (SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer AND c2.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_body,
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_direction, (SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer AND c3.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_direction,
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
FROM chat_messages c1 FROM chat_messages c1
WHERE room_uid IS NULL
GROUP BY peer GROUP BY peer
ORDER BY last_at DESC
")->fetchAll(); ")->fetchAll();
$totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows));
// Group rooms — join rooms to their newest message (if any); rooms
// with no messages yet still render so the user can post there.
$rooms = $db->query("
SELECT
'room' AS kind,
r.uid AS key,
r.name AS label,
r.owner_username AS owner,
(SELECT MAX(sent_at) FROM chat_messages WHERE room_uid = r.uid) AS last_at,
(SELECT body FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_body,
(SELECT peer FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_author,
(SELECT COUNT(*) FROM chat_messages WHERE room_uid = r.uid AND direction = 'in' AND is_read = 0) AS unread
FROM chat_rooms r
")->fetchAll();
$rows = array_merge($oneOnOne, $rooms);
// Sort newest-first, nulls (empty rooms) to the bottom.
usort($rows, function ($a, $b) {
$al = $a['last_at'] ?? ''; $bl = $b['last_at'] ?? '';
if ($al === $bl) return 0;
if ($al === '') return 1;
if ($bl === '') return -1;
return strcmp($bl, $al);
});
$totalUnread = array_sum(array_map(fn($r) => (int)($r['unread'] ?? 0), $rows));
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]); chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
} }
if ($route === '/api/chat/messages' && $method === 'GET') { if ($route === '/api/chat/messages' && $method === 'GET') {
$peer = trim((string)($_GET['peer'] ?? '')); $peer = trim((string)($_GET['peer'] ?? ''));
if ($peer === '') chatJson(['error' => 'peer required'], 400); $roomUid = trim((string)($_GET['room_uid'] ?? ''));
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$db = Database::connect(); $db = Database::connect();
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500'); if ($roomUid !== '') {
$stmt->execute(['p' => $peer]); // Group thread: return all messages for the room. `peer` is the
// author username; UI uses author === self to decide bubble
// alignment instead of the direction flag (direction is set
// from THIS device's perspective, so a message sent from
// another device comes in as direction='in' while peer==self).
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen, room_uid FROM chat_messages WHERE room_uid = :r ORDER BY id ASC LIMIT 500');
$stmt->execute(['r' => $roomUid]);
} else {
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p AND room_uid IS NULL ORDER BY id ASC LIMIT 500');
$stmt->execute(['p' => $peer]);
}
chatJson(['messages' => $stmt->fetchAll()]); chatJson(['messages' => $stmt->fetchAll()]);
} }
if ($route === '/api/chat/send' && $method === 'POST') { if ($route === '/api/chat/send' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: []; $input = json_decode(file_get_contents('php://input'), true) ?: [];
$peer = trim((string)($input['peer'] ?? '')); $peer = trim((string)($input['peer'] ?? ''));
$roomUid = trim((string)($input['room_uid'] ?? ''));
$body = (string)($input['body'] ?? ''); $body = (string)($input['body'] ?? '');
if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400); if ($body === '') chatJson(['error' => 'body required'], 400);
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$r = chatRelaySettings(); $r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) { if (!$r['url'] || !$r['user'] || !$r['key']) {
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400); chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
} }
$db = Database::connect();
// Look up per-thread disposable flag (1:1 only; rooms are always
// persistent on the relay).
$disposable = 0;
if ($peer !== '') {
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
$stmt->execute(['p' => $peer]);
$disposable = (int)($stmt->fetchColumn() ?: 0);
}
$envelope = chatEncrypt($body, $r['key']); $envelope = chatEncrypt($body, $r['key']);
$payload = $roomUid !== ''
? ['room_uid' => $roomUid, 'body' => $envelope]
: ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable];
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat'; $relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
$ch = curl_init($relayChatUrl); $ch = curl_init($relayChatUrl);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]), CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [ CURLOPT_HTTPHEADER => [
'Content-Type: application/json', 'Content-Type: application/json',
'X-Relay-User: ' . $r['user'], 'X-Relay-User: ' . $r['user'],
@@ -176,11 +269,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$relayId = isset($resData['id']) ? (int)$resData['id'] : null; $relayId = isset($resData['id']) ? (int)$resData['id'] : null;
// Store the outgoing message locally so the UI can render it // Store the outgoing message locally so the UI can render it
// immediately. relay_id is the relay's autoincrement id; the // immediately. For group messages `peer` holds the author
// receipt-poll matches against it to update delivered/seen state. // (= self); for 1:1 it holds the other party as before.
$db = Database::connect(); $localPeer = $roomUid !== '' ? $r['user'] : $peer;
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)') $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen, room_uid) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0, :ru)')
->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]); ->execute([
'p' => $localPeer,
'b' => $body,
'rid' => $relayId,
'ru' => $roomUid !== '' ? $roomUid : null,
]);
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
} }
@@ -217,20 +315,78 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$db = Database::connect(); $db = Database::connect();
$newCount = 0; $newCount = 0;
$insert = $db->prepare('INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "in", :b, :rid, :ts, 0)'); // `mine` on group messages (== came back from the relay because
// another device of ours sent it) gets direction='out' so the UI
// renders it aligned right; relay_id uniqueness dedupes against
// the local insert we did on send.
$insert = $db->prepare('
INSERT OR IGNORE INTO chat_messages
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
VALUES (:p, :dir, :b, :rid, :ts, 0, :ru)
');
foreach ($messages as $m) { foreach ($messages as $m) {
$plain = chatDecrypt($m['body'] ?? '', $r['key']); $plain = chatDecrypt($m['body'] ?? '', $r['key']);
if ($plain === '') continue; if ($plain === '') continue;
$isGroup = !empty($m['room_uid']);
$mine = !empty($m['mine']);
$insert->execute([ $insert->execute([
'p' => $m['sender_username'] ?? '?', 'p' => $m['sender_username'] ?? '?',
'b' => $plain, 'dir' => $mine ? 'out' : 'in',
'b' => $plain,
'rid' => (int)$m['id'], 'rid' => (int)$m['id'],
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'), 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
'ru' => $isGroup ? $m['room_uid'] : null,
]); ]);
if ($insert->rowCount() > 0) $newCount++; if ($insert->rowCount() > 0) $newCount++;
} }
if ($maxId > $since) chatSetState('last_relay_id', $maxId); if ($maxId > $since) chatSetState('last_relay_id', $maxId);
// Mirror rooms + membership into the local cache on every poll.
// Cheap (one extra GET), and it lets a second device see a room
// it was just added to without a separate fetch.
$ch3 = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
curl_setopt_array($ch3, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
CURLOPT_TIMEOUT => 10,
]);
$resp3 = curl_exec($ch3);
$st3 = curl_getinfo($ch3, CURLINFO_HTTP_CODE);
curl_close($ch3);
if ($st3 === 200) {
$roomsData = json_decode($resp3, true) ?: [];
$rooms = $roomsData['rooms'] ?? [];
$remoteUids = [];
$upsert = $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, :c, datetime("now"))');
$delMem = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u');
$insMem = $db->prepare('INSERT OR IGNORE INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
foreach ($rooms as $room) {
$uid = (string)($room['uid'] ?? '');
if ($uid === '') continue;
$remoteUids[] = $uid;
$upsert->execute([
'u' => $uid,
'n' => (string)($room['name'] ?? $uid),
'o' => (string)($room['owner_username'] ?? ''),
'c' => (string)($room['created_at'] ?? date('Y-m-d H:i:s')),
]);
$delMem->execute(['u' => $uid]);
foreach (($room['members'] ?? []) as $member) {
$insMem->execute(['u' => $uid, 'un' => (string)$member]);
}
}
// Drop local rooms we were removed from (owner kick / self-leave
// on another device). Messages for them stay on disk — harmless,
// UI won't list them without a room entry.
$existing = $db->query('SELECT uid FROM chat_rooms')->fetchAll(PDO::FETCH_COLUMN);
foreach ($existing as $uid) {
if (!in_array($uid, $remoteUids, true)) {
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
}
}
}
// ── Receipt poll for our SENT messages ────────────────── // ── Receipt poll for our SENT messages ──────────────────
// Pulls fetched/seen state for everything we've sent. Updates // Pulls fetched/seen state for everything we've sent. Updates
// local outgoing rows by relay_id. Uses an independent `since` // local outgoing rows by relay_id. Uses an independent `since`
@@ -288,9 +444,18 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route === '/api/chat/read' && $method === 'POST') { if ($route === '/api/chat/read' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: []; $input = json_decode(file_get_contents('php://input'), true) ?: [];
$peer = trim((string)($input['peer'] ?? '')); $peer = trim((string)($input['peer'] ?? ''));
if ($peer === '') chatJson(['error' => 'peer required'], 400); $roomUid = trim((string)($input['room_uid'] ?? ''));
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$db = Database::connect(); $db = Database::connect();
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0') if ($roomUid !== '') {
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE room_uid = :r AND direction = "in" AND is_read = 0')
->execute(['r' => $roomUid]);
// Group "seen" receipts aren't a thing in v1 — skip the relay
// notification. The local is_read flag is enough for badge UI.
chatJson(['ok' => true]);
}
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND room_uid IS NULL AND direction = "in" AND is_read = 0')
->execute(['p' => $peer]); ->execute(['p' => $peer]);
// Tell the relay this peer's messages have been seen by us so the // Tell the relay this peer's messages have been seen by us so the
@@ -338,4 +503,199 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$data = json_decode($response, true) ?: []; $data = json_decode($response, true) ?: [];
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]); chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
} }
// ── Rooms CRUD (proxy to relay) ──────────────────────────────
if ($route === '/api/chat/rooms' && $method === 'GET') {
$db = Database::connect();
$rooms = $db->query('SELECT uid, name, owner_username, created_at FROM chat_rooms ORDER BY created_at DESC')->fetchAll();
$mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :u ORDER BY username');
foreach ($rooms as &$room) {
$mstmt->execute(['u' => $room['uid']]);
$room['members'] = array_column($mstmt->fetchAll(), 'username');
}
chatJson(['ok' => true, 'rooms' => $rooms]);
}
if ($route === '/api/chat/rooms' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$name = trim((string)($input['name'] ?? ''));
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
if ($name === '') chatJson(['error' => 'name required'], 400);
if (count(array_filter($members, fn($m) => trim((string)$m) !== '')) === 0) {
chatJson(['error' => 'at least one other member required'], 400);
}
$r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
// Client-generated UID, same convention as collab buckets.
$uid = bin2hex(random_bytes(8));
$relayUrl = rtrim($r['url'], '/') . '/api/chat/rooms';
$ch = curl_init($relayUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['uid' => $uid, 'name' => $name, 'members' => $members]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$err = json_decode($response, true);
$detail = $err['error'] ?? "relay HTTP $status";
// 404 on this path almost always means the relay is running a
// pre-group-chat build. Surface that explicitly so the user
// doesn't go hunting for data issues.
if ($status === 404) {
$detail = "relay does not know /api/chat/rooms — it's likely running old code. Restart the relay after pulling the latest commits.";
}
chatJson(['ok' => false, 'error' => $detail, 'url' => $relayUrl, 'relay_status' => $status]);
}
$data = json_decode($response, true) ?: [];
// Mirror locally right away so the next conversations list shows
// the room without waiting for a poll.
$db = Database::connect();
$db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, datetime("now"), datetime("now"))')
->execute(['u' => $uid, 'n' => $name, 'o' => $r['user']]);
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
$insMem = $db->prepare('INSERT INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
foreach (($data['members'] ?? []) as $mu) {
$insMem->execute(['u' => $uid, 'un' => $mu]);
}
chatJson(['ok' => true, 'uid' => $uid, 'members' => $data['members'] ?? []]);
}
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
$uid = $m[1];
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// The next poll will refresh the local room mirror with the new
// name / member list. No need to replay the update here.
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
}
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
$uid = $m[1];
$r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) {
$db = Database::connect();
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
}
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
}
// ── Backfill older messages for a thread (scrollback / cold start) ──
if ($route === '/api/chat/backfill' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$peer = trim((string)($input['peer'] ?? ''));
$roomUid = trim((string)($input['room_uid'] ?? ''));
$before = (int)($input['before'] ?? 0);
$limit = max(1, min(500, (int)($input['limit'] ?? 100)));
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
$r = chatRelaySettings();
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
$qs = http_build_query(array_filter([
'room_uid' => $roomUid ?: null,
'peer' => $peer ?: null,
'before' => $before > 0 ? $before : null,
'limit' => $limit,
], fn($v) => $v !== null));
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/backfill?' . $qs);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
$err = json_decode($response, true);
chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
}
$data = json_decode($response, true) ?: [];
$messages = $data['messages'] ?? [];
$db = Database::connect();
$newCount = 0;
$insert = $db->prepare('
INSERT OR IGNORE INTO chat_messages
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
VALUES (:p, :dir, :b, :rid, :ts, 1, :ru)
');
foreach ($messages as $m) {
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
if ($plain === '') continue;
$insert->execute([
'p' => $m['sender_username'] ?? '?',
'dir' => !empty($m['mine']) ? 'out' : 'in',
'b' => $plain,
'rid' => (int)$m['id'],
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
'ru' => !empty($m['room_uid']) ? $m['room_uid'] : null,
]);
if ($insert->rowCount() > 0) $newCount++;
}
chatJson(['ok' => true, 'backfilled' => $newCount]);
}
// ── Per-thread settings (currently just the disposable flag) ──
if ($route === '/api/chat/thread-settings' && $method === 'GET') {
$peer = trim((string)($_GET['peer'] ?? ''));
if ($peer === '') chatJson(['error' => 'peer required'], 400);
$db = Database::connect();
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
$stmt->execute(['p' => $peer]);
$disposable = (int)($stmt->fetchColumn() ?: 0);
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
}
if ($route === '/api/chat/thread-settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$peer = trim((string)($input['peer'] ?? ''));
$disposable = !empty($input['disposable']) ? 1 : 0;
if ($peer === '') chatJson(['error' => 'peer required'], 400);
$db = Database::connect();
$db->prepare('INSERT OR REPLACE INTO chat_thread_settings (peer, disposable) VALUES (:p, :d)')
->execute(['p' => $peer, 'd' => $disposable]);
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
}
}); });
+206 -4
View File
@@ -29,6 +29,23 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Combined caret + title trigger. Navigable (cursor + accent hover)
only when there's somewhere to go back to — toggled by JS via the
`.navigable` class. On the list it reads like a static label. */
.chat-header-nav {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
padding: 0;
color: var(--t-bright);
cursor: default;
transition: color 0.12s;
}
.chat-header-nav.navigable { cursor: pointer; color: var(--t-muted); }
.chat-header-nav.navigable:hover { color: var(--t-accent); }
.chat-view { .chat-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,15 +159,58 @@
/* ── Thread view ── */ /* ── Thread view ── */
#chat-thread-header { /* Horizontal peer strip above the thread header. Lets the user hop
between conversations without bouncing back to the list. Scrollbar
hidden — wheel-over scrolls horizontally (see chat.js). */
.chat-thread-peers {
display: flex; display: flex;
align-items: center; gap: 5px;
gap: 8px; padding: 6px 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--t-border); border-bottom: 1px solid var(--t-border);
background: var(--t-bg); background: var(--t-bg);
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.chat-thread-peers::-webkit-scrollbar { display: none; }
.chat-thread-peers:empty { display: none; }
.chat-peer-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-button, 3px);
background: var(--t-surface);
color: var(--t-muted);
font-family: var(--t-font-pixel);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s, background 0.12s;
position: relative;
}
.chat-peer-tab:hover {
border-color: var(--t-accent);
color: var(--t-accent);
}
.chat-peer-tab.active {
border-color: var(--t-accent);
background: var(--t-accent);
color: var(--t-bg);
}
.chat-peer-tab .chat-peer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--t-accent);
flex-shrink: 0; flex-shrink: 0;
} }
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
.chat-thread-messages { .chat-thread-messages {
flex: 1; flex: 1;
@@ -286,6 +346,148 @@
color: var(--t-accent); color: var(--t-accent);
} }
.chat-pick-row.selected {
background: var(--t-raised);
color: var(--t-accent);
}
.chat-pick-row .chat-pick-check {
margin-left: auto;
color: var(--t-accent);
font-size: 12px;
}
.chat-pick-row:not(.selected) .chat-pick-check { visibility: hidden; }
/* Picker mode switch (1:1 vs. group). */
.chat-pick-mode {
display: flex;
gap: 4px;
padding: 6px 10px;
border-bottom: 1px solid var(--t-border);
background: var(--t-bg);
flex-shrink: 0;
}
.chat-pick-mode-btn {
flex: 1;
padding: 3px 8px;
border: 1px solid var(--t-border);
background: transparent;
color: var(--t-muted);
font-family: var(--t-font-pixel);
font-size: 10px;
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background 0.12s;
}
.chat-pick-mode-btn:hover { color: var(--t-accent); border-color: var(--t-accent); }
.chat-pick-mode-btn.active {
border-color: var(--t-accent);
background: var(--t-accent);
color: var(--t-bg);
}
.chat-group-name {
margin: 8px 10px 4px;
padding: 5px 8px;
background: var(--t-surface);
border: 1px solid var(--t-border);
color: var(--t-bright);
font-size: 11px;
font-family: var(--t-font-mono, monospace);
outline: none;
}
.chat-group-name:focus { border-color: var(--t-accent); }
.chat-group-name.hidden { display: none; }
.chat-group-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-top: 1px solid var(--t-border);
background: var(--t-bg);
flex-shrink: 0;
}
.chat-group-actions.hidden { display: none; }
/* Room-info view: member list + owner actions. */
#chat-room-info-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--t-border);
background: var(--t-bg);
flex-shrink: 0;
}
.chat-room-info-body {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-room-info-body .section-label {
font-family: var(--t-font-pixel);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--t-dim);
margin-top: 4px;
}
.chat-room-member-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--t-bright);
padding: 3px 0;
}
.chat-room-member-row .owner-badge {
font-family: var(--t-font-pixel);
font-size: 8px;
color: var(--t-accent);
background: var(--t-raised);
padding: 1px 5px;
border: 1px solid var(--t-accent);
margin-left: 4px;
}
.chat-room-info-body .btn {
padding: 4px 10px;
font-size: 10px;
font-family: var(--t-font-pixel);
border: 1px solid var(--t-border);
background: transparent;
color: var(--t-muted);
cursor: pointer;
transition: all 0.12s;
}
.chat-room-info-body .btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
.chat-room-info-body .btn.danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
/* Author name above group-thread bubbles. Shown only when the author
changes, so consecutive bubbles from the same person aren't repeated.
Left-aligned so it sits above the left-aligned incoming bubble. */
.chat-bubble-author {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
color: var(--t-accent2, var(--t-accent));
margin-top: 6px;
padding: 0 2px;
letter-spacing: 0.02em;
}
/* Peer-tab variant for rooms — group icon instead of initial. */
.chat-peer-tab .chat-peer-tab-icon {
font-size: 10px;
flex-shrink: 0;
}
/* Disposable / info icons in flyout header. */
#chat-thread-disposable.active {
color: var(--t-accent);
}
/* ── Footer button ── */ /* ── Footer button ── */
#chat-footer-btn { #chat-footer-btn {
+40 -11
View File
@@ -3,9 +3,19 @@
<div id="chat-flyout" class="hidden"> <div id="chat-flyout" class="hidden">
<div id="chat-flyout-header"> <div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i> <i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span> <!-- Caret + "chat" label are a single trigger with one tooltip.
<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> The caret shows only when there's somewhere to go back to
<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> (thread / pick / room-info); on the list itself the click
is a no-op and the button styles itself as inert. -->
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
</button>
<span class="flex-1"></span>
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button> <button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
</div> </div>
@@ -21,24 +31,43 @@
<!-- Conversation thread view --> <!-- Conversation thread view -->
<div id="chat-thread-view" class="chat-view hidden"> <div id="chat-thread-view" class="chat-view hidden">
<div id="chat-thread-header"> <div id="chat-thread-peers" class="chat-thread-peers"></div>
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<i class="ph ph-user-circle text-t-accent text-sm"></i>
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
</div>
<div id="chat-thread-messages" class="chat-thread-messages"></div> <div id="chat-thread-messages" class="chat-thread-messages"></div>
<form id="chat-thread-form"> <form id="chat-thread-form">
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000"> <input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
<button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" title="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button> <button type="submit" id="chat-thread-send" class="text-t-accent hover:brightness-125" data-tip="send"><i class="ph ph-paper-plane-tilt text-sm"></i></button>
</form> </form>
</div> </div>
<!-- New-conversation peer picker --> <!-- New-conversation peer picker / group creation.
Shared view: a mode toggle at top switches between picking one
peer (1:1) and building a group (name + multi-select). -->
<div id="chat-pick-view" class="chat-view hidden"> <div id="chat-pick-view" class="chat-view hidden">
<div id="chat-pick-header"> <div id="chat-pick-header">
<button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button> <button id="chat-pick-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<span class="font-pixel text-[10px] text-t-bright flex-1">pick a recipient</span> <span id="chat-pick-title" class="font-pixel text-[10px] text-t-bright flex-1">new conversation</span>
</div> </div>
<div id="chat-pick-mode" class="chat-pick-mode">
<button class="chat-pick-mode-btn active" data-mode="peer" type="button">1:1</button>
<button class="chat-pick-mode-btn" data-mode="group" type="button">group</button>
</div>
<input id="chat-group-name" class="chat-group-name hidden" type="text" autocomplete="off" placeholder="group name..." maxlength="80">
<div id="chat-pick-list" class="chat-pick-list"></div> <div id="chat-pick-list" class="chat-pick-list"></div>
<div id="chat-group-actions" class="chat-group-actions hidden">
<span id="chat-group-count" class="font-pixel text-[9px] text-t-dim"></span>
<span style="flex:1"></span>
<button id="chat-group-create" type="button" class="px-2 py-1 text-[10px] font-pixel border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors">Create</button>
</div>
</div>
<!-- Room info / member management view.
Shown when the user taps the info icon in a room thread. -->
<div id="chat-room-info-view" class="chat-view hidden">
<div id="chat-room-info-header">
<button id="chat-room-info-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<i class="ph ph-users-three text-t-accent text-sm"></i>
<span id="chat-room-info-name" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
</div>
<div id="chat-room-info-body" class="chat-room-info-body"></div>
</div> </div>
</div> </div>
+481 -84
View File
@@ -3,6 +3,12 @@
// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop // Footer button + bottom-right flyout. Polls /api/chat/poll on a loop
// (faster while open, slower while closed). Blinks the footer button // (faster while open, slower while closed). Blinks the footer button
// when new incoming messages arrive while the flyout is closed. // when new incoming messages arrive while the flyout is closed.
//
// Threads come in two kinds:
// peer — classic 1:1 with a single relay tenant. Disposable flag per
// thread drops messages from the relay after delivery.
// room — named group chat with a member list. Always persistent on
// the relay so every device / late joiner has full history.
const hyprcrd = window.hyprcrd; const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing'); if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing');
@@ -12,16 +18,18 @@ const $ = (id) => document.getElementById(id);
// ── State ───────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────
let conversations = []; let conversations = [];
let currentPeer = null; // Null = list view. Otherwise: { kind: 'peer'|'room', key, label }.
let currentThread = null;
let currentMessages = []; let currentMessages = [];
let pollHandle = null; let pollHandle = null;
let pollIntervalMs = 20000; // closed default let pollIntervalMs = 20000;
let recipientCache = null; // { tenants: [...], self: '...' } let recipientCache = null; // { tenants: [...], self: '...' }
let initialPollDone = false; let initialPollDone = false;
// Chat polls aggressively because the endpoint is cheap (single indexed // Group creation pick-view state. Reset on every pick-view open.
// SELECT with LIMIT 500). Card sync stays on its own slower interval — let pickMode = 'peer'; // 'peer' or 'group'
// CalDAV is the expensive one. Tab-hidden pauses the timer entirely. let selectedMembers = new Set();
const POLL_OPEN_MS = 8000; const POLL_OPEN_MS = 8000;
const POLL_CLOSED_MS = 20000; const POLL_CLOSED_MS = 20000;
@@ -49,6 +57,36 @@ function isOpen() {
return !$('chat-flyout')?.classList.contains('hidden'); return !$('chat-flyout')?.classList.contains('hidden');
} }
function threadFromItem(item) {
return { kind: item.kind || 'peer', key: item.key ?? item.peer, label: item.label ?? item.peer };
}
function sameThread(a, b) {
return a && b && a.kind === b.kind && a.key === b.key;
}
function threadBody(thread) {
return thread.kind === 'room' ? { room_uid: thread.key } : { peer: thread.key };
}
function threadStorageKey(thread) {
return `${thread.kind}:${thread.key}`;
}
function parseStorageKey(str) {
if (!str) return null;
const i = str.indexOf(':');
if (i < 0) return { kind: 'peer', key: str, label: str };
return { kind: str.slice(0, i), key: str.slice(i + 1), label: str.slice(i + 1) };
}
async function getSelfUsername() {
if (recipientCache?.self) return recipientCache.self;
const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null);
if (res?.ok) recipientCache = res;
return recipientCache?.self || '';
}
// ── Footer button ───────────────────────────────────────────────── // ── Footer button ─────────────────────────────────────────────────
function injectFooterButton() { function injectFooterButton() {
@@ -56,7 +94,6 @@ function injectFooterButton() {
if (!footer) return; if (!footer) return;
if (document.getElementById('chat-footer-btn')) return; if (document.getElementById('chat-footer-btn')) return;
// Append next to the help button on the right side of the footer
const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type'); const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type');
if (!rightGroup) return; if (!rightGroup) return;
@@ -101,29 +138,29 @@ async function toggleFlyout() {
async function openFlyout() { async function openFlyout() {
$('chat-flyout').classList.remove('hidden'); $('chat-flyout').classList.remove('hidden');
// Capture blink state BEFORE we clear it — it tells us a peer wrote since last close
const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking'); const wasBlinking = $('chat-footer-btn')?.classList.contains('blinking');
stopBlink(); stopBlink();
pollIntervalMs = POLL_OPEN_MS; pollIntervalMs = POLL_OPEN_MS;
restartPoll(); restartPoll();
await loadConversations(); await loadConversations();
await pollOnce(); // immediate refresh on open await pollOnce();
await loadConversations(); await loadConversations();
// Pick the right initial view: // Initial view: newest unread thread after a blink, else the last
// 1. If blinking → newest peer with unread (= someone wrote to us) // thread the user opened (peer or room), else the list.
// 2. Else → last peer the user opened manually (persisted)
// 3. Else → list view
let target = null; let target = null;
if (wasBlinking) { if (wasBlinking) {
const unread = conversations const unread = conversations
.filter(c => parseInt(c.unread) > 0) .filter(c => parseInt(c.unread) > 0)
.sort((a, b) => String(b.last_at).localeCompare(String(a.last_at))); .sort((a, b) => String(b.last_at || '').localeCompare(String(a.last_at || '')));
if (unread.length) target = unread[0].peer; if (unread.length) target = threadFromItem(unread[0]);
} }
if (!target) { if (!target) {
const lastPeer = localStorage.getItem('chat-last-peer'); const remembered = parseStorageKey(localStorage.getItem('chat-last-thread'));
if (lastPeer && conversations.some(c => c.peer === lastPeer)) target = lastPeer; if (remembered && conversations.some(c => c.kind === remembered.kind && c.key === remembered.key)) {
const item = conversations.find(c => c.kind === remembered.kind && c.key === remembered.key);
target = threadFromItem(item);
}
} }
if (target) await openConversation(target); if (target) await openConversation(target);
else showListView(); else showListView();
@@ -137,27 +174,55 @@ function closeFlyout() {
// ── Views ───────────────────────────────────────────────────────── // ── Views ─────────────────────────────────────────────────────────
function showListView() { function hideAllViews() {
$('chat-list-view').classList.remove('hidden'); ['chat-list-view', 'chat-thread-view', 'chat-pick-view', 'chat-room-info-view']
$('chat-thread-view').classList.add('hidden'); .forEach(id => $(id)?.classList.add('hidden'));
$('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat';
currentPeer = null;
} }
function showThreadView(peer) { // Toggle the caret + make the "chat" trigger look/feel clickable only
$('chat-list-view').classList.add('hidden'); // when there's actually somewhere to go back to.
$('chat-thread-view').classList.remove('hidden'); function setNavState(canBack) {
$('chat-pick-view').classList.add('hidden'); $('chat-header-back-caret')?.classList.toggle('hidden', !canBack);
$('chat-header-nav')?.classList.toggle('navigable', canBack);
}
function showListView() {
hideAllViews();
$('chat-list-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-thread-peer').textContent = peer; setNavState(false);
currentPeer = peer; $('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
currentThread = null;
}
function showThreadView(thread) {
hideAllViews();
$('chat-thread-view').classList.remove('hidden');
$('chat-flyout-title').textContent = 'chat';
setNavState(true);
currentThread = thread;
// Room-only: info icon. 1:1-only: disposable icon.
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
$('chat-thread-disposable')?.classList.toggle('hidden', thread.kind !== 'peer');
if (thread.kind === 'peer') refreshDisposableIcon(thread.key);
renderPeerTabs();
} }
function showPickView() { function showPickView() {
$('chat-list-view').classList.add('hidden'); hideAllViews();
$('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.remove('hidden'); $('chat-pick-view').classList.remove('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
}
function showRoomInfoView() {
hideAllViews();
$('chat-room-info-view').classList.remove('hidden');
setNavState(false);
$('chat-thread-disposable')?.classList.add('hidden');
$('chat-thread-info')?.classList.add('hidden');
} }
// ── Conversation list ───────────────────────────────────────────── // ── Conversation list ─────────────────────────────────────────────
@@ -174,6 +239,7 @@ function renderConversations() {
const list = $('chat-list'); const list = $('chat-list');
const empty = $('chat-empty'); const empty = $('chat-empty');
if (!list || !empty) return; if (!list || !empty) return;
renderPeerTabs();
if (!conversations.length) { if (!conversations.length) {
list.innerHTML = ''; list.innerHTML = '';
empty.classList.remove('hidden'); empty.classList.remove('hidden');
@@ -181,19 +247,29 @@ function renderConversations() {
} }
empty.classList.add('hidden'); empty.classList.add('hidden');
list.innerHTML = conversations.map(c => { list.innerHTML = conversations.map(c => {
const initial = (c.peer || '?').charAt(0).toUpperCase(); const isRoom = c.kind === 'room';
const label = c.label || c.key || '?';
const initial = label.charAt(0).toUpperCase();
const avatar = isRoom
? '<div class="chat-list-avatar"><i class="ph ph-users-three"></i></div>'
: `<div class="chat-list-avatar">${escapeHtml(initial)}</div>`;
const unreadBadge = parseInt(c.unread) > 0 const unreadBadge = parseInt(c.unread) > 0
? `<span class="chat-unread-badge">${c.unread}</span>` : ''; ? `<span class="chat-unread-badge">${c.unread}</span>` : '';
const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview'; const previewClass = (!isRoom && c.last_direction === 'out')
? 'chat-list-preview outgoing'
: 'chat-list-preview';
const preview = isRoom && c.last_author
? `${escapeHtml(c.last_author)}: ${escapeHtml((c.last_body || '').slice(0, 70))}`
: escapeHtml((c.last_body || '').slice(0, 80));
return ` return `
<div class="chat-list-row" data-peer="${escapeHtml(c.peer)}"> <div class="chat-list-row" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}">
<div class="chat-list-avatar">${escapeHtml(initial)}</div> ${avatar}
<div class="chat-list-info"> <div class="chat-list-info">
<div class="chat-list-peer"> <div class="chat-list-peer">
<span>${escapeHtml(c.peer)}</span> <span>${escapeHtml(label)}</span>
<span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span> <span class="chat-list-time">${escapeHtml(formatChatTime(c.last_at))}</span>
</div> </div>
<div class="${previewClass}">${escapeHtml((c.last_body || '').slice(0, 80))}</div> <div class="${previewClass}">${preview}</div>
</div> </div>
${unreadBadge} ${unreadBadge}
</div> </div>
@@ -201,51 +277,133 @@ function renderConversations() {
}).join(''); }).join('');
list.querySelectorAll('.chat-list-row').forEach(row => { list.querySelectorAll('.chat-list-row').forEach(row => {
row.addEventListener('click', () => openConversation(row.dataset.peer)); row.addEventListener('click', () => {
const item = conversations.find(c => c.kind === row.dataset.kind && c.key === row.dataset.key);
if (item) openConversation(threadFromItem(item));
});
}); });
} }
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
// Horizontal row above the thread so the user can hop between
// conversations without going back to the list. Hidden scrollbar;
// vertical wheel delta is redirected to horizontal scroll.
function renderPeerTabs() {
const strip = $('chat-thread-peers');
if (!strip) return;
const tabs = conversations.slice();
// Fold the current thread in even if it's brand-new (e.g. just
// created a room and haven't synced conversations yet), so the
// active tab is always visible.
if (currentThread && !tabs.some(c => c.kind === currentThread.kind && c.key === currentThread.key)) {
tabs.unshift({ kind: currentThread.kind, key: currentThread.key, label: currentThread.label, unread: 0 });
}
if (!tabs.length) { strip.innerHTML = ''; return; }
strip.innerHTML = tabs.map(c => {
const isRoom = c.kind === 'room';
const active = currentThread && currentThread.kind === c.kind && currentThread.key === c.key ? ' active' : '';
const notActive = !active;
const dot = parseInt(c.unread) > 0 && notActive
? '<span class="chat-peer-dot"></span>' : '';
const icon = isRoom
? '<i class="ph ph-users-three chat-peer-tab-icon"></i>'
: '';
const label = c.label || c.key || '?';
return `<button class="chat-peer-tab${active}" data-kind="${isRoom ? 'room' : 'peer'}" data-key="${escapeHtml(c.key)}" title="${escapeHtml(label)}">${dot}${icon}<span>${escapeHtml(label)}</span></button>`;
}).join('');
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const thread = { kind: tab.dataset.kind, key: tab.dataset.key, label: tab.querySelector('span')?.textContent || tab.dataset.key };
if (!sameThread(currentThread, thread)) openConversation(thread);
});
});
const active = strip.querySelector('.chat-peer-tab.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function wirePeerStripWheel() {
const strip = $('chat-thread-peers');
if (!strip || strip.dataset.wheelWired) return;
strip.dataset.wheelWired = '1';
strip.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (strip.scrollWidth <= strip.clientWidth) return;
e.preventDefault();
strip.scrollLeft += e.deltaY;
}, { passive: false });
}
// ── Thread ──────────────────────────────────────────────────────── // ── Thread ────────────────────────────────────────────────────────
async function openConversation(peer) { async function openConversation(thread) {
showThreadView(peer); showThreadView(thread);
localStorage.setItem('chat-last-peer', peer); localStorage.setItem('chat-last-thread', threadStorageKey(thread));
await loadThread(peer); await loadThread(thread);
// Mark all incoming as read // Backfill older messages from the relay on open so a second device /
await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {}); // cold cache catches up. Fire-and-forget: the re-render happens via
// Refresh badges in the background // the subsequent conversations refresh and the next poll tick.
backfillThread(thread).catch(() => {});
await hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(thread) }).catch(() => {});
loadConversations(); loadConversations();
setTimeout(() => $('chat-thread-input')?.focus(), 50); setTimeout(() => $('chat-thread-input')?.focus(), 50);
} }
async function loadThread(peer) { async function loadThread(thread) {
const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer)) const qs = thread.kind === 'room'
.then(r => r.json()).catch(() => null); ? 'room_uid=' + encodeURIComponent(thread.key)
: 'peer=' + encodeURIComponent(thread.key);
const res = await fetch('api/chat/messages?' + qs).then(r => r.json()).catch(() => null);
if (!res) return; if (!res) return;
currentMessages = res.messages || []; currentMessages = res.messages || [];
renderThread(); await renderThread();
} }
function renderThread() { async function backfillThread(thread) {
const body = threadBody(thread);
// Oldest id we have locally is the lower bound for the backfill —
// we want messages BEFORE it.
const lowest = currentMessages.reduce((min, m) => {
const id = Number(m.id || 0);
return min === 0 || (id > 0 && id < min) ? id : min;
}, 0);
const res = await hyprcrd.api('api/chat/backfill', {
method: 'POST',
body: { ...body, before: lowest, limit: 100 },
}).catch(() => null);
if (res?.ok && (res.backfilled || 0) > 0 && sameThread(thread, currentThread)) {
await loadThread(thread);
}
}
async function renderThread() {
const wrap = $('chat-thread-messages'); const wrap = $('chat-thread-messages');
if (!wrap) return; if (!wrap) return;
const self = await getSelfUsername();
const isRoom = currentThread?.kind === 'room';
let lastAuthor = null;
wrap.innerHTML = currentMessages.map(m => { wrap.innerHTML = currentMessages.map(m => {
const cls = m.direction === 'out' ? 'out' : 'in'; // For groups: author == self → "out" bubble regardless of the
// Read receipts for outgoing messages: single check = recipient // stored direction (which reflects which device inserted the row).
// polled (delivered), double check = recipient opened the thread const mine = isRoom ? (m.peer === self) : (m.direction === 'out');
// (seen). Incoming messages get no icon. const cls = mine ? 'out' : 'in';
let receipt = ''; let receipt = '';
if (m.direction === 'out') { if (!isRoom && mine) {
if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>'; if (Number(m.seen)) receipt = '<i class="ph ph-checks chat-receipt seen"></i>';
else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>'; else if (Number(m.delivered)) receipt = '<i class="ph ph-check chat-receipt delivered"></i>';
else receipt = '<i class="ph ph-clock chat-receipt pending"></i>'; else receipt = '<i class="ph ph-clock chat-receipt pending"></i>';
} }
let authorLine = '';
if (isRoom && !mine && m.peer !== lastAuthor) {
authorLine = `<div class="chat-bubble-author">${escapeHtml(m.peer || '')}</div>`;
}
lastAuthor = m.peer;
return ` return `
${authorLine}
<div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div> <div class="chat-bubble ${cls}">${escapeHtml(m.body)}</div>
<div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div> <div class="chat-bubble-time">${escapeHtml(formatChatTime(m.sent_at))}${receipt}</div>
`; `;
}).join(''); }).join('');
// Scroll to bottom
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
} }
@@ -253,7 +411,7 @@ async function sendMessage(e) {
e?.preventDefault(); e?.preventDefault();
const input = $('chat-thread-input'); const input = $('chat-thread-input');
const body = input.value.trim(); const body = input.value.trim();
if (!body || !currentPeer) return; if (!body || !currentThread) return;
const sendBtn = $('chat-thread-send'); const sendBtn = $('chat-thread-send');
sendBtn.disabled = true; sendBtn.disabled = true;
input.disabled = true; input.disabled = true;
@@ -261,7 +419,7 @@ async function sendMessage(e) {
try { try {
const res = await hyprcrd.api('api/chat/send', { const res = await hyprcrd.api('api/chat/send', {
method: 'POST', method: 'POST',
body: { peer: currentPeer, body }, body: { ...threadBody(currentThread), body },
}); });
if (!res.ok) { if (!res.ok) {
const detail = [ const detail = [
@@ -279,7 +437,7 @@ async function sendMessage(e) {
return; return;
} }
input.value = ''; input.value = '';
await loadThread(currentPeer); await loadThread(currentThread);
await loadConversations(); await loadConversations();
} catch (err) { } catch (err) {
await hyprcrd.dialog.show({ await hyprcrd.dialog.show({
@@ -296,10 +454,15 @@ async function sendMessage(e) {
} }
} }
// ── Pick recipient ──────────────────────────────────────────────── // ── Pick recipient / group creation ───────────────────────────────
async function openPickView() { async function openPickView() {
showPickView(); showPickView();
pickMode = 'peer';
selectedMembers = new Set();
applyPickMode();
$('chat-group-name').value = '';
const list = $('chat-pick-list'); const list = $('chat-pick-list');
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>'; list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel">loading...</div>';
@@ -311,25 +474,243 @@ async function openPickView() {
} }
recipientCache = res; recipientCache = res;
} }
renderPickList();
}
const tenants = recipientCache.tenants || []; function applyPickMode() {
const nameInput = $('chat-group-name');
const actions = $('chat-group-actions');
const title = $('chat-pick-title');
document.querySelectorAll('.chat-pick-mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === pickMode);
});
if (pickMode === 'group') {
nameInput.classList.remove('hidden');
actions.classList.remove('hidden');
if (title) title.textContent = 'new group';
updateGroupCount();
} else {
nameInput.classList.add('hidden');
actions.classList.add('hidden');
if (title) title.textContent = 'new conversation';
}
renderPickList();
}
function renderPickList() {
const list = $('chat-pick-list');
if (!list || !recipientCache) return;
const self = recipientCache.self; const self = recipientCache.self;
const others = tenants.filter(t => t.username !== self); const others = (recipientCache.tenants || []).filter(t => t.username !== self);
if (!others.length) { if (!others.length) {
list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>'; list.innerHTML = '<div class="px-3 py-3 text-[10px] text-t-dim font-pixel cursor-blink">no other users on the relay</div>';
return; return;
} }
list.innerHTML = others.map(t => ` list.innerHTML = others.map(t => {
<div class="chat-pick-row" data-peer="${escapeHtml(t.username)}"> const selected = selectedMembers.has(t.username);
<i class="ph ph-user-circle text-sm"></i> const selCls = (pickMode === 'group' && selected) ? ' selected' : '';
<span>${escapeHtml(t.username)}</span> const check = pickMode === 'group'
</div> ? `<i class="ph ph-check chat-pick-check"></i>`
`).join(''); : '';
return `
<div class="chat-pick-row${selCls}" data-peer="${escapeHtml(t.username)}">
<i class="ph ph-user-circle text-sm"></i>
<span>${escapeHtml(t.username)}</span>
${check}
</div>
`;
}).join('');
list.querySelectorAll('.chat-pick-row').forEach(row => { list.querySelectorAll('.chat-pick-row').forEach(row => {
row.addEventListener('click', () => openConversation(row.dataset.peer)); row.addEventListener('click', () => {
const u = row.dataset.peer;
if (pickMode === 'group') {
if (selectedMembers.has(u)) selectedMembers.delete(u);
else selectedMembers.add(u);
renderPickList();
updateGroupCount();
} else {
openConversation({ kind: 'peer', key: u, label: u });
}
});
}); });
} }
function updateGroupCount() {
const el = $('chat-group-count');
if (!el) return;
const n = selectedMembers.size;
el.textContent = n ? `${n} member${n === 1 ? '' : 's'} selected` : 'pick at least one member';
}
async function createRoom() {
const name = $('chat-group-name').value.trim();
if (!name) {
await hyprcrd.dialog.show({ title: 'missing name', message: 'Give the group a name.', okLabel: 'Ok', cancelLabel: '' });
return;
}
if (!selectedMembers.size) {
await hyprcrd.dialog.show({ title: 'no members', message: 'Pick at least one other member.', okLabel: 'Ok', cancelLabel: '' });
return;
}
const res = await hyprcrd.api('api/chat/rooms', {
method: 'POST',
body: { name, members: [...selectedMembers] },
});
if (!res?.ok) {
await hyprcrd.dialog.show({
title: 'create failed',
message: res?.error || 'unknown error',
danger: true,
okLabel: 'Ok',
cancelLabel: '',
});
return;
}
await loadConversations();
openConversation({ kind: 'room', key: res.uid, label: name });
}
// ── Room info / member management ──────────────────────────────
async function openRoomInfo() {
if (currentThread?.kind !== 'room') return;
const savedThread = currentThread;
showRoomInfoView();
$('chat-room-info-name').textContent = savedThread.label || savedThread.key;
const body = $('chat-room-info-body');
body.innerHTML = '<div class="text-[10px] text-t-dim font-pixel">loading...</div>';
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
const room = (res?.rooms || []).find(r => r.uid === savedThread.key);
const self = await getSelfUsername();
if (!room) {
body.innerHTML = '<div class="text-[10px] text-t-danger font-pixel">room not found</div>';
return;
}
const isOwner = room.owner_username === self;
const others = (res?.tenants || recipientCache?.tenants || [])
.filter(t => t.username !== self && !(room.members || []).includes(t.username));
const memberRows = (room.members || []).map(u => {
const ownerTag = u === room.owner_username ? '<span class="owner-badge">owner</span>' : '';
const removeBtn = (isOwner && u !== room.owner_username)
? `<button class="btn danger" data-action="remove" data-user="${escapeHtml(u)}" style="margin-left:auto;padding:2px 8px;">remove</button>`
: '';
return `
<div class="chat-room-member-row">
<i class="ph ph-user-circle text-t-accent"></i>
<span>${escapeHtml(u)}</span>
${ownerTag}
${removeBtn}
</div>
`;
}).join('');
const addSection = isOwner && (recipientCache?.tenants || []).length > (room.members || []).length
? `<div class="section-label">Add members</div>
<div id="chat-room-add-list">${
(recipientCache?.tenants || [])
.filter(t => t.username !== self && !(room.members || []).includes(t.username))
.map(t => `<div class="chat-room-member-row"><i class="ph ph-user-circle"></i><span>${escapeHtml(t.username)}</span><button class="btn" style="margin-left:auto;padding:2px 8px;" data-action="add" data-user="${escapeHtml(t.username)}">add</button></div>`)
.join('') || '<div class="text-[10px] text-t-dim font-pixel">everyone on the relay is already in</div>'
}</div>`
: '';
body.innerHTML = `
<div>
<div class="section-label">Members (${(room.members || []).length})</div>
${memberRows || '<div class="text-[10px] text-t-dim">no members</div>'}
</div>
${addSection}
<div style="display:flex;gap:8px;margin-top:12px;">
${isOwner
? '<button class="btn danger" data-action="delete" style="flex:1">Delete room</button>'
: '<button class="btn danger" data-action="leave" style="flex:1">Leave room</button>'
}
<button class="btn" data-action="back" style="flex:1">Back to chat</button>
</div>
`;
body.querySelectorAll('button[data-action]').forEach(btn => {
btn.addEventListener('click', () => handleRoomAction(btn.dataset.action, btn.dataset.user, savedThread));
});
}
async function handleRoomAction(action, user, thread) {
const uid = thread.key;
if (action === 'back') {
openConversation(thread);
return;
}
if (action === 'remove' || action === 'add') {
// Fetch current room, mutate members, PATCH.
const res = await fetch('api/chat/rooms').then(r => r.json()).catch(() => null);
const room = (res?.rooms || []).find(r => r.uid === uid);
if (!room) return;
let members = [...(room.members || [])];
if (action === 'remove') members = members.filter(m => m !== user);
else if (!members.includes(user)) members.push(user);
// Relay adds owner automatically, but we forward the full list
// minus the owner (owner ALWAYS stays on relay side).
const patchRes = await hyprcrd.api(`api/chat/rooms/${uid}`, {
method: 'PATCH',
body: { members },
});
if (!patchRes?.ok) {
await hyprcrd.dialog.show({ title: 'update failed', message: patchRes?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
}
await pollOnce(); // refresh local rooms mirror
await openRoomInfo(); // re-render
return;
}
if (action === 'leave' || action === 'delete') {
const confirm = await hyprcrd.dialog.show({
title: action === 'delete' ? 'Delete room?' : 'Leave room?',
message: action === 'delete'
? 'All members lose access and the message history is wiped.'
: 'You will stop receiving messages in this room.',
danger: true,
okLabel: action === 'delete' ? 'Delete' : 'Leave',
});
if (!confirm) return;
const res = await hyprcrd.api(`api/chat/rooms/${uid}`, { method: 'DELETE' });
if (!res?.ok) {
await hyprcrd.dialog.show({ title: 'failed', message: res?.error || 'unknown', danger: true, okLabel: 'Ok', cancelLabel: '' });
return;
}
await pollOnce();
await loadConversations();
showListView();
}
}
// ── Disposable toggle (1:1) ──────────────────────────────────────
async function refreshDisposableIcon(peer) {
const btn = $('chat-thread-disposable');
if (!btn) return;
const res = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
.then(r => r.json()).catch(() => null);
const on = !!res?.disposable;
btn.classList.toggle('active', on);
btn.setAttribute('data-tip', on
? 'disposable: new messages drop after delivery — click to persist'
: 'persistent: messages live on the relay — click to make disposable');
}
async function toggleDisposable() {
if (currentThread?.kind !== 'peer') return;
const peer = currentThread.key;
const cur = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
.then(r => r.json()).catch(() => ({ disposable: 0 }));
const next = cur.disposable ? 0 : 1;
await hyprcrd.api('api/chat/thread-settings', {
method: 'POST',
body: { peer, disposable: next },
});
refreshDisposableIcon(peer);
}
// ── Polling ─────────────────────────────────────────────────────── // ── Polling ───────────────────────────────────────────────────────
async function pollOnce() { async function pollOnce() {
@@ -342,20 +723,15 @@ async function pollOnce() {
const receiptUpdates = res.receipts || 0; const receiptUpdates = res.receipts || 0;
if (newCount > 0) { if (newCount > 0) {
await loadConversations(); await loadConversations();
// If we're inside a thread for any peer, refresh + auto-read it if (isOpen() && currentThread) {
if (isOpen() && currentPeer) { await loadThread(currentThread);
await loadThread(currentPeer); hyprcrd.api('api/chat/read', { method: 'POST', body: threadBody(currentThread) })
hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } })
.then(() => loadConversations()).catch(() => {}); .then(() => loadConversations()).catch(() => {});
} }
// Blink if the flyout is closed
if (!isOpen()) startBlink(); if (!isOpen()) startBlink();
} else if (receiptUpdates > 0 && isOpen() && currentPeer) { } else if (receiptUpdates > 0 && isOpen() && currentThread) {
// No new incoming, but receipts (delivered/seen) changed for await loadThread(currentThread);
// our outgoing — refresh the thread so the check icons update.
await loadThread(currentPeer);
} else if (initialPollDone === false) { } else if (initialPollDone === false) {
// First poll: refresh badges in case we have unread from a previous session
await loadConversations(); await loadConversations();
} }
initialPollDone = true; initialPollDone = true;
@@ -380,16 +756,36 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations)); $('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView); $('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView); $('chat-empty-start')?.addEventListener('click', openPickView);
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); }); // Caret + "chat" label are one button with one tooltip. Click is a
// no-op when the caret is hidden (already on the list) — the CSS
// .navigable class tracks whether the button looks clickable.
$('chat-header-nav')?.addEventListener('click', () => {
if (!$('chat-header-nav')?.classList.contains('navigable')) return;
showListView();
loadConversations();
});
$('chat-pick-back').addEventListener('click', showListView); $('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage); $('chat-thread-form').addEventListener('submit', sendMessage);
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
$('chat-thread-disposable')?.addEventListener('click', toggleDisposable);
$('chat-room-info-back')?.addEventListener('click', () => {
if (currentThread) openConversation(currentThread);
else showListView();
});
$('chat-group-create')?.addEventListener('click', createRoom);
document.querySelectorAll('.chat-pick-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
pickMode = btn.dataset.mode;
applyPickMode();
});
});
wirePeerStripWheel();
// Esc closes flyout when focus is inside it
$('chat-flyout').addEventListener('keydown', e => { $('chat-flyout').addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); } if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); }
}); });
// Defer the first POST (poll) until auth has set csrf. GETs are fine immediately.
loadConversations().catch(() => {}); loadConversations().catch(() => {});
const startPolling = () => { const startPolling = () => {
pollOnce().catch(() => {}); pollOnce().catch(() => {});
@@ -401,7 +797,6 @@ function init() {
hyprcrd.hooks.on('csrf.ready', startPolling); hyprcrd.hooks.on('csrf.ready', startPolling);
} }
// Pause polling when the page is hidden
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (document.hidden) {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
@@ -410,6 +805,8 @@ function init() {
restartPoll(); restartPoll();
} }
}); });
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -420,6 +817,6 @@ if (document.readyState === 'loading') {
hyprcrd.registerPlugin?.({ hyprcrd.registerPlugin?.({
name: 'chat', name: 'chat',
version: '1.0.0', version: '1.1.0',
description: 'Encrypted chat between relay tenants', description: 'Encrypted chat between relay tenants (1:1 + group rooms)',
}); });
+162 -9
View File
@@ -49,10 +49,20 @@
bucket_uid TEXT NOT NULL, bucket_uid TEXT NOT NULL,
last_push_at TEXT, last_push_at TEXT,
last_remote_at TEXT, last_remote_at TEXT,
last_seen_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
) )
"); ");
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
// Per-card unread tracking (added 2026-04-14). Existing deployments need
// the column added explicitly.
$ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name');
if (!in_array('last_seen_at', $ccsCols)) {
$db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT");
// Backfill: everything existing gets seen=now so we don't flood the
// user with a board full of red dots after they deploy this.
$db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')");
}
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards // Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
// table. The poll's push phase drains it and sends each entry as a // table. The poll's push phase drains it and sends each entry as a
@@ -411,9 +421,16 @@ function collabSyncCards(PDO $db): array {
$stats['moved']++; $stats['moved']++;
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) { if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
// Card is gone or moved out of collab entirely — forget about it // Card is gone or moved out of collab entirely — forget about it.
// Critical: also clear the card's relay_uid so the incoming
// tombstone (echoed back from the relay on next pull) doesn't
// find this card and delete it locally too.
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id') $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
->execute(['id' => $mv['card_id']]); ->execute(['id' => $mv['card_id']]);
if (!$mv['card_missing']) {
$db->prepare('UPDATE cards SET relay_uid = NULL WHERE id = :id')
->execute(['id' => $mv['card_id']]);
}
} else { } else {
// Card moved to a different collab bucket. Rewrite state so the // Card moved to a different collab bucket. Rewrite state so the
// push phase below picks it up under the new bucket_uid and also // push phase below picks it up under the new bucket_uid and also
@@ -436,17 +453,19 @@ function collabSyncCards(PDO $db): array {
FROM cards c FROM cards c
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
LEFT JOIN collab_card_state cs ON cs.card_id = c.id LEFT JOIN collab_card_state cs ON cs.card_id = c.id
WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at WHERE (cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at)
AND c.updated_at < datetime('now', '-2 seconds')
")->fetchAll(); ")->fetchAll();
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id'); $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
$stateUpsert = $db->prepare(" $stateUpsert = $db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL)) VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2)
ON CONFLICT(card_id) DO UPDATE SET ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid, relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid, bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at last_push_at = excluded.last_push_at,
last_seen_at = excluded.last_seen_at
"); ");
foreach ($dirty as $card) { foreach ($dirty as $card) {
@@ -459,6 +478,31 @@ function collabSyncCards(PDO $db): array {
$payload = collabEncryptPayload(collabBuildCardPayload($card)); $payload = collabEncryptPayload(collabBuildCardPayload($card));
if ($payload === null) continue; // no relay_key or openssl failure if ($payload === null) continue; // no relay_key or openssl failure
// Attachments FIRST, card second. If a peer polls between the two
// HTTP calls, they'd see the new card body with an `uploads/xyz.png`
// reference but the attachment wouldn't be on the relay yet — their
// fetch returns empty and the image renders broken permanently
// (the pull short-circuits on the card's next poll because
// updated_at doesn't change). Pushing attachments first guarantees
// the relay's state is consistent: if the card is visible, its
// images are fetchable.
$body = $card['body'] ?? '';
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
foreach (array_unique($fm[1]) as $fn) {
$localPath = UPLOAD_DIR . '/' . $fn;
if (!is_file($localPath)) continue;
$bytes = file_get_contents($localPath);
if ($bytes === false) continue;
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
if ($encoded === null) continue;
collabRelayCall('/api/collab-attachments', 'POST', [
'bucket_uid' => $card['bucket_uid'],
'filename' => $fn,
'data' => $encoded,
]);
}
}
$res = collabRelayCall('/api/collab-cards', 'POST', [ $res = collabRelayCall('/api/collab-cards', 'POST', [
'bucket_uid' => $card['bucket_uid'], 'bucket_uid' => $card['bucket_uid'],
'card_uid' => $relayUid, 'card_uid' => $relayUid,
@@ -483,6 +527,7 @@ function collabSyncCards(PDO $db): array {
'ru' => $relayUid, 'ru' => $relayUid,
'bu' => $card['bucket_uid'], 'bu' => $card['bucket_uid'],
'push' => $res['updated_at'] ?? $card['updated_at'], 'push' => $res['updated_at'] ?? $card['updated_at'],
'push2'=> $res['updated_at'] ?? $card['updated_at'],
]); ]);
$stats['pushed']++; $stats['pushed']++;
} }
@@ -543,6 +588,33 @@ function collabSyncCards(PDO $db): array {
$fields = collabDecryptPayload($c['payload'] ?? ''); $fields = collabDecryptPayload($c['payload'] ?? '');
if ($fields === null) continue; if ($fields === null) continue;
// Fetch referenced attachments BEFORE applying the body so that
// the moment state.cards reflects the new body (via _reloadAll),
// every `uploads/xxx.png` reference is already on disk. Without
// this, a user who opens the card preview between apply and
// fetch would get a broken-image render — and the browser's
// 404 response can stick around in the page's image cache even
// after the file finally lands.
$pulledBody = $fields['body'] ?? '';
if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) {
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
foreach (array_unique($fm[1]) as $fn) {
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn));
if (!($attRes['ok'] ?? false)) continue;
foreach (($attRes['attachments'] ?? []) as $a) {
if (($a['filename'] ?? '') !== $fn) continue;
$decoded = collabDecryptPayload($a['data'] ?? '');
if ($decoded && isset($decoded['raw'])) {
$bytes = base64_decode($decoded['raw']);
if ($bytes !== false) file_put_contents($dest, $bytes);
}
}
}
}
if ($local) { if ($local) {
// LWW: only accept remote if it's strictly newer than local // LWW: only accept remote if it's strictly newer than local
if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue; if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
@@ -602,24 +674,49 @@ function collabSyncCards(PDO $db): array {
$localId = (int)$db->lastInsertId(); $localId = (int)$db->lastInsertId();
} }
// Record the remote state so we don't push it back on the next poll // Record the remote state so we don't push it back on the next
// poll. last_seen_at semantics: self-authored updates (our own
// echo) mark as seen; foreign-authored updates leave last_seen_at
// untouched so the red dot fires on the board.
$seenAt = $foreignAuthor ? null : $c['updated_at'];
$db->prepare(" $db->prepare("
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at) INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
VALUES (:cid, :ru, :bu, :p, :r) VALUES (:cid, :ru, :bu, :p, :r, :s)
ON CONFLICT(card_id) DO UPDATE SET ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid, relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid, bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at, last_push_at = excluded.last_push_at,
last_remote_at = excluded.last_remote_at last_remote_at = excluded.last_remote_at,
last_seen_at = CASE
WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at
ELSE collab_card_state.last_seen_at
END
")->execute([ ")->execute([
'cid' => $localId, 'cid' => $localId,
'ru' => $c['card_uid'], 'ru' => $c['card_uid'],
'bu' => $c['bucket_uid'], 'bu' => $c['bucket_uid'],
'p' => $c['updated_at'], 'p' => $c['updated_at'],
'r' => $c['updated_at'], 'r' => $c['updated_at'],
's' => $seenAt,
]); ]);
$stats['pulled']++; $stats['pulled']++;
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
// Notify the card's author that we synced it (best-effort sync
// event → toast on the author's side). Only fires for cards
// authored by someone else.
if ($foreignAuthor && !empty($c['author_username'])) {
$cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)';
$evRes = collabRelayCall('/api/sync-events', 'POST', [
'recipient' => $c['author_username'],
'body' => $selfUser . ' synced: ' . $cardTitle,
]);
@error_log('[hyprcrd] sync-event POST → ' . $c['author_username']
. ' ok=' . ($evRes['ok'] ?? 'n/a')
. ' status=' . ($evRes['status'] ?? '-')
. ' err=' . substr((string)($evRes['error'] ?? ''), 0, 120));
}
} }
} finally { } finally {
// Bump `collab_buckets.updated_at` for every bucket that received // Bump `collab_buckets.updated_at` for every bucket that received
@@ -641,6 +738,40 @@ function collabSyncCards(PDO $db): array {
} }
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
// Self-heal phase: walk every card in a collab bucket and try to fetch
// any `uploads/xxx.png` reference whose file isn't on disk. Covers the
// historical race where a card body arrived before its attachment, as
// well as any one-off fetch failure (network blip, timeout) that left
// a card with a broken image reference. Normal-case cost is trivial:
// one SQL, one `file_exists()` per reference, no HTTP when everything
// is already in place.
$missing = $db->query("
SELECT c.id, c.body, b.collab_uid AS bucket_uid
FROM cards c
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
WHERE c.body LIKE '%uploads/%'
")->fetchAll();
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
foreach ($missing as $mc) {
if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue;
foreach (array_unique($fm[1]) as $fn) {
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn));
if (!($attRes['ok'] ?? false)) continue;
foreach (($attRes['attachments'] ?? []) as $a) {
if (($a['filename'] ?? '') !== $fn) continue;
$decoded = collabDecryptPayload($a['data'] ?? '');
if ($decoded && isset($decoded['raw'])) {
$bytes = base64_decode($decoded['raw']);
if ($bytes !== false) file_put_contents($dest, $bytes);
}
}
}
}
return $stats; return $stats;
} }
@@ -837,6 +968,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// Any pending outbox entries still waiting for a reachable relay // Any pending outbox entries still waiting for a reachable relay
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn(); $outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
// Per-card unread set: cards whose local updated_at is newer than
// their last_seen_at (or never seen). Frontend uses this to draw
// the little red dot on the board card.
$unreadCards = array_map('intval', $db->query("
SELECT cs.card_id
FROM collab_card_state cs
JOIN cards c ON c.id = cs.card_id
WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at
")->fetchAll(PDO::FETCH_COLUMN));
collabJson([ collabJson([
'ok' => true, 'ok' => true,
'self' => collabRelaySettings()['user'], 'self' => collabRelaySettings()['user'],
@@ -847,6 +988,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
'locks' => $locks, 'locks' => $locks,
'outbox_processed' => $outboxProcessed, 'outbox_processed' => $outboxProcessed,
'outbox_pending' => $outboxPending, 'outbox_pending' => $outboxPending,
'unread_cards' => $unreadCards,
]); ]);
} }
@@ -861,6 +1003,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
collabJson(['ok' => true]); collabJson(['ok' => true]);
} }
// ── Mark a single collab card as seen (clears its red dot) ────
if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$cardId = (int)($input['card_id'] ?? 0);
if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400);
Database::connect()
->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c")
->execute(['c' => $cardId]);
collabJson(['ok' => true]);
}
// ── Proxy: relay tenants (for the create-bucket member picker) ── // ── Proxy: relay tenants (for the create-bucket member picker) ──
// The relay's /api/tenants returns {tenants, self} without an `ok` field, // The relay's /api/tenants returns {tenants, self} without an `ok` field,
// so we wrap it here to match the shape the frontend expects. // so we wrap it here to match the shape the frontend expects.
@@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
.collab-conflict-toast.collab-lock-toast b { .collab-conflict-toast.collab-lock-toast b {
color: var(--t-accent2); color: var(--t-accent2);
} }
/* ── Per-card unread indicator ──
Mirror of the bucket-tab red dot: a card with a foreign edit we haven't
opened yet gets a small glowing dot pinned to the top-right corner. */
.board-card.collab-card-unread { position: relative; }
.board-card.collab-card-unread .collab-card-dot {
position: absolute;
top: -3px;
right: -3px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--t-danger);
border: 1px solid var(--t-bg);
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
pointer-events: none;
z-index: 3;
}
+114 -17
View File
@@ -4,9 +4,9 @@
// rows in the standard bucket bar. The plugin just owns the sync + the // rows in the standard bucket bar. The plugin just owns the sync + the
// visual unread-state decoration: // visual unread-state decoration:
// //
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend // - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
// mirrors each remote bucket into the main `buckets` table so the core // in sync.js). The backend mirrors each remote bucket into the main
// bucket bar renders it automatically. // `buckets` table so the core bucket bar renders it automatically.
// - Exposes `window._collabBucketsCreateDialog` — an async function that // - Exposes `window._collabBucketsCreateDialog` — an async function that
// the core + Bucket button calls so we can pop a richer "new bucket" // the core + Bucket button calls so we can pop a richer "new bucket"
// modal with an optional "collaborative" checkbox + member picker. // modal with an optional "collaborative" checkbox + member picker.
@@ -24,8 +24,11 @@ const $ = (id) => document.getElementById(id);
// uid -> collab bucket meta (from /api/collab-buckets) // uid -> collab bucket meta (from /api/collab-buckets)
let collabByUid = new Map(); let collabByUid = new Map();
let pollHandle = null; // card_id (number) -> true for cards with foreign edits we haven't opened yet
const POLL_INTERVAL_MS = 30000; let unreadCards = new Set();
// Collab card sync is driven by the shared `sync.tick` hook from
// sync.js — one timer fans out to every plugin poller, the central
// timer honors the Autosync pause for us.
// Phase 4: edit locks (now exclusive — server-enforced) // Phase 4: edit locks (now exclusive — server-enforced)
// card_uid -> { bucket_uid, holder_username, expires_at } // card_uid -> { bucket_uid, holder_username, expires_at }
@@ -58,8 +61,16 @@ async function loadCollabMeta() {
} catch (err) { /* silent; relay may be offline */ } } catch (err) { /* silent; relay may be offline */ }
} }
let pollLocked = false;
async function pollOnce() { async function pollOnce() {
if (pollLocked) return;
// Don't poll while user is actively editing — the push would race
// with the in-progress save and corrupt card state.
if (document.getElementById('modal')?.classList.contains('open')) return;
try { try {
pollLocked = true;
hyprcrd.hooks?.emit?.('sync.pulse');
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' }); const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return; if (!res?.ok) return;
if (res.self) window._collabSelf = res.self; if (res.self) window._collabSelf = res.self;
@@ -85,6 +96,12 @@ async function pollOnce() {
const n = res.outbox_processed; const n = res.outbox_processed;
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`); showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
} }
// Per-card unread list: cards with foreign edits we haven't opened
// yet. Decorated on the board via decorateBoardUnread().
if (Array.isArray(res.unread_cards)) {
unreadCards = new Set(res.unread_cards.map(Number));
decorateBoardUnread();
}
// Phase 4: edit-lock list comes back with the poll // Phase 4: edit-lock list comes back with the poll
if (Array.isArray(res.locks)) { if (Array.isArray(res.locks)) {
const next = new Map(); const next = new Map();
@@ -112,7 +129,7 @@ async function pollOnce() {
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : []; const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
showBlockedToast(by); showBlockedToast(by);
} }
} catch { /* silent */ } } catch { /* silent */ } finally { pollLocked = false; }
} }
function showConflictToast(n) { function showConflictToast(n) {
@@ -149,13 +166,11 @@ function showQueuedToast(message) {
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000); setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
} }
function startPolling() { // Subscribe to the global sync heartbeat. The central timer in sync.js
stopPolling(); // honors the Autosync pause + the configurable interval for us, and
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS); // the header Sync button emits the same tick so manual sync pushes
} // collab cards too.
function stopPolling() { hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
// ── Bucket bar decoration ───────────────────────────────────────── // ── Bucket bar decoration ─────────────────────────────────────────
@@ -377,6 +392,18 @@ async function createBucketDialog() {
} }
}); });
// Enter in the name field = Create (users expect the submit path
// without reaching for the mouse). Stop propagation so nothing
// upstream grabs it. No-op if name is empty — the submit button's
// own handler validates.
overlay.querySelector('#__cb_name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
overlay.querySelector('.collab-btn-submit').click();
}
});
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50); setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
}); });
} }
@@ -631,12 +658,71 @@ function decorateBoardLocks() {
} }
} }
/**
* Walk every rendered board card and add / remove the "red dot" unread
* indicator for cards with foreign edits we haven't opened yet.
* Idempotent — safe to re-run on every board re-render.
*/
function decorateBoardUnread() {
const board = document.getElementById('board');
if (!board) return;
for (const el of board.querySelectorAll('.board-card[data-id]')) {
const cid = parseInt(el.dataset.id);
const isUnread = unreadCards.has(cid);
el.classList.toggle('collab-card-unread', isUnread);
let dot = el.querySelector('.collab-card-dot');
if (isUnread && !dot) {
dot = document.createElement('span');
dot.className = 'collab-card-dot';
el.appendChild(dot);
} else if (!isUnread && dot) {
dot.remove();
}
}
}
// Clear a card's unread state locally + on the backend. Called from
// - board click (piggy-backing on the natural open-card interaction)
// - detail.opened hook (when the detail view opens via any path:
// keyboard shortcut, related-card link, backlink, search result, etc.)
function clearCardUnread(cid) {
cid = parseInt(cid);
if (!cid || !unreadCards.has(cid)) return;
unreadCards.delete(cid);
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
if (el) {
el.classList.remove('collab-card-unread');
el.querySelector('.collab-card-dot')?.remove();
}
hyprcrd.api('api/collab-buckets/mark-card-seen', {
method: 'POST', body: { card_id: cid },
}).catch(() => {});
}
function setupCardClickInterceptor() {
const board = document.getElementById('board');
if (board) {
board.addEventListener('click', (e) => {
const el = e.target.closest('.board-card[data-id]');
if (el) clearCardUnread(el.dataset.id);
});
}
// Detail view has its own open path (keyboard shortcuts, backlinks,
// related-card navigation) that doesn't route through a board-card
// click. Listen for the explicit detail.opened hook so every open
// method clears the dot.
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
}
// Re-decorate whenever the board re-renders (reuses the existing #board // Re-decorate whenever the board re-renders (reuses the existing #board
// MutationObserver pattern). // MutationObserver pattern).
function setupBoardObserver() { function setupBoardObserver() {
const board = document.getElementById('board'); const board = document.getElementById('board');
if (!board) return; if (!board) return;
const obs = new MutationObserver(() => decorateBoardLocks()); const obs = new MutationObserver(() => {
decorateBoardLocks();
decorateBoardUnread();
});
obs.observe(board, { childList: true, subtree: false }); obs.observe(board, { childList: true, subtree: false });
} }
@@ -892,19 +978,30 @@ async function init() {
} }
setupBarObserver(); setupBarObserver();
setupClickInterceptor(); setupClickInterceptor();
setupCardClickInterceptor();
setupBoardObserver(); setupBoardObserver();
await loadCollabMeta(); await loadCollabMeta();
await pollOnce(); await pollOnce();
await loadCollabMeta(); await loadCollabMeta();
decorateBucketBar(); decorateBucketBar();
decorateBoardLocks(); decorateBoardLocks();
startPolling(); decorateBoardUnread();
// No local timer: subsequent polls come from the global `sync.tick`
// hook (central timer in sync.js honors Autosync pause + interval).
} }
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
// boot, and firing POST /api/collab-buckets/poll before that racing lookup
// resolves returns 403. If the token is already in the util state when we
// load (e.g. fast reload, cached session), kick off init straight away.
function bootWhenCsrfReady() {
if (hyprcrd?.util?.csrf?.()) { init(); return; }
hyprcrd?.hooks?.on?.('csrf.ready', () => init());
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true }); document.addEventListener('DOMContentLoaded', bootWhenCsrfReady, { once: true });
} else { } else {
init(); bootWhenCsrfReady();
} }
hyprcrd.registerPlugin?.({ hyprcrd.registerPlugin?.({
+12 -12
View File
@@ -7,10 +7,10 @@
<i class="ph ph-envelope text-t-accent text-base"></i> <i class="ph ph-envelope text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">mail</h2> <h2 class="font-pixel text-xs text-t-accent">mail</h2>
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select> <select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
<button id="email-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button> <button id="email-refresh" class="toolbar-btn" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span> <span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button> <button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="manage accounts"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button> <button id="email-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
</div> </div>
@@ -41,11 +41,11 @@
<div class="sticky top-0 z-20 bg-t-bg"> <div class="sticky top-0 z-20 bg-t-bg">
<!-- List toolbar: pagination + spam quick-select --> <!-- List toolbar: pagination + spam quick-select -->
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border"> <div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
<button id="email-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button> <button id="email-page-prev" class="toolbar-btn" data-tip="previous page"><i class="ph ph-caret-left text-sm"></i></button>
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button> <button id="email-page-next" class="toolbar-btn" data-tip="next page"><i class="ph ph-caret-right text-sm"></i></button>
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span> <span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Select all messages flagged as spam"> <button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="select spam">
<i class="ph ph-warning-octagon text-sm"></i> <i class="ph ph-warning-octagon text-sm"></i>
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span> <span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
</button> </button>
@@ -54,16 +54,16 @@
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised"> <div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span> <span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Mark sender as spam (future mail from same sender will be flagged)"> <button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="mark as spam">
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span> <i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">Spam</span>
</button> </button>
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)"> <button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" data-tip="not spam">
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span> <i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">Not spam</span>
</button> </button>
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server"> <button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" data-tip="Delete selected on server">
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span> <i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">Delete</span>
</button> </button>
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection"> <button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Clear selection">
<i class="ph ph-x text-sm"></i> <i class="ph ph-x text-sm"></i>
</button> </button>
</div> </div>
@@ -91,9 +91,9 @@
</div> </div>
<div class="mt-3 flex items-center gap-2 flex-wrap"> <div class="mt-3 flex items-center gap-2 flex-wrap">
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button> <button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">Extract as card</span></button>
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button> <button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">View as html</span></button>
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button> <button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">View as text</span></button>
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button> <button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" data-tip="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">Load images (<span id="email-blocked-count">0</span>)</span></button>
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span> <span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
</div> </div>
</div> </div>
+6 -6
View File
@@ -218,9 +218,9 @@ function renderAccountsList() {
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div> <div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
</div> </div>
<div class="email-account-actions"> <div class="email-account-actions">
<button class="toolbar-btn email-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button> <button class="toolbar-btn email-edit-btn" data-tip="edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button> <button class="toolbar-btn email-test-btn" data-tip="test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn email-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button> <button class="toolbar-btn email-del-btn" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -452,13 +452,13 @@ function renderMessageList() {
const spamScore = m.spam?.score ?? 0; const spamScore = m.spam?.score ?? 0;
const spamReasons = (m.spam?.reasons || []).join(' · '); const spamReasons = (m.spam?.reasons || []).join(' · ');
const spamBadge = spamLevel === 'spam' const spamBadge = spamLevel === 'spam'
? `<span class="email-spam-badge spam" title="${escapeHtml(spamReasons)}">⚠ spam</span>` ? `<span class="email-spam-badge spam" data-tip="${escapeHtml(spamReasons)}">⚠ spam</span>`
: spamLevel === 'suspicious' : spamLevel === 'suspicious'
? `<span class="email-spam-badge suspicious" title="${escapeHtml(spamReasons)}">? suspicious</span>` ? `<span class="email-spam-badge suspicious" data-tip="${escapeHtml(spamReasons)}">? suspicious</span>`
: ''; : '';
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : ''; const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
return ` return `
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" title="${escapeHtml(rowTitle)}"> <div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" data-tip="${escapeHtml(rowTitle)}">
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message"> <input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
<div class="email-msg-content"> <div class="email-msg-content">
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
@@ -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, name TEXT NOT NULL,
base_url TEXT NOT NULL, base_url TEXT NOT NULL,
token TEXT NOT NULL, token TEXT NOT NULL,
default_repo TEXT DEFAULT NULL,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) )
"); ");
// Migration: add default_repo column if missing
$cols = $db->query("PRAGMA table_info(gitea_accounts)")->fetchAll(PDO::FETCH_COLUMN, 1);
if (!in_array('default_repo', $cols)) {
$db->exec("ALTER TABLE gitea_accounts ADD COLUMN default_repo TEXT DEFAULT NULL");
}
})(); })();
// ── Encryption helpers (mirrors email plugin / CalDavSync) ──────── // ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
@@ -85,7 +91,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route === '/api/gitea/accounts' && $method === 'GET') { if ($route === '/api/gitea/accounts' && $method === 'GET') {
$db = Database::connect(); $db = Database::connect();
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll(); $rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
giteaJson($rows); giteaJson($rows);
} }
@@ -132,6 +138,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if (!empty($input['token'])) { if (!empty($input['token'])) {
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']); $fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
} }
if (array_key_exists('default_repo', $input)) {
$val = trim((string)($input['default_repo'] ?? ''));
$fields[] = 'default_repo = :default_repo'; $params['default_repo'] = $val ?: null;
}
if (!$fields) giteaJson(['ok' => true]); if (!$fields) giteaJson(['ok' => true]);
$db = Database::connect(); $db = Database::connect();
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); $db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
+15 -15
View File
@@ -9,7 +9,7 @@
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select> <select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span> <span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button> <button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">Accounts</span></button>
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button> <button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span></button>
</div> </div>
@@ -40,25 +40,25 @@
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border"> <div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
<!-- Filters bar --> <!-- Filters bar -->
<div class="flex items-center gap-2 px-3 py-1.5"> <div class="flex items-center gap-2 px-3 py-1.5">
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Issue state"> <select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Issue state">
<option value="open">open</option> <option value="open">open</option>
<option value="closed">closed</option> <option value="closed">closed</option>
<option value="all">all</option> <option value="all">all</option>
</select> </select>
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Filter by your involvement"> <select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" data-tip="Filter by your involvement">
<option value="all">everyone</option> <option value="all">everyone</option>
<option value="assigned">assigned to me</option> <option value="assigned">assigned to me</option>
<option value="created">created by me</option> <option value="created">created by me</option>
<option value="mentioned">mentioning me</option> <option value="mentioned">mentioning me</option>
</select> </select>
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono"> <input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
<button id="gitea-new-issue" class="toolbar-btn" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button> <button id="gitea-new-issue" class="toolbar-btn" data-tip="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button> <button id="gitea-refresh" class="toolbar-btn" data-tip="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
</div> </div>
<!-- Pagination toolbar --> <!-- Pagination toolbar -->
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border"> <div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
<button id="gitea-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button> <button id="gitea-page-prev" class="toolbar-btn" data-tip="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button> <button id="gitea-page-next" class="toolbar-btn" data-tip="Next page"><i class="ph ph-caret-right text-sm"></i></button>
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span> <span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
</div> </div>
</div> </div>
@@ -83,7 +83,7 @@
<i class="ph ph-plus-square text-t-accent text-lg"></i> <i class="ph ph-plus-square text-t-accent text-lg"></i>
<h3 class="text-t-bright text-base font-bold">new issue</h3> <h3 class="text-t-bright text-base font-bold">new issue</h3>
<div class="flex-1"></div> <div class="flex-1"></div>
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button> <button id="gitea-create-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm"></i></button>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block relative"> <label class="block relative">
@@ -140,12 +140,12 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div id="gitea-title-display" class="flex items-center gap-2"> <div id="gitea-title-display" class="flex items-center gap-2">
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3> <h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button> <button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
</div> </div>
<div id="gitea-title-edit" class="hidden flex items-center gap-2"> <div id="gitea-title-edit" class="hidden flex items-center gap-2">
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono"> <input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
<button id="gitea-title-save" class="toolbar-btn" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button> <button id="gitea-title-save" class="toolbar-btn" data-tip="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button> <button id="gitea-title-cancel" class="toolbar-btn" data-tip="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
</div> </div>
</div> </div>
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span> <span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
@@ -158,7 +158,7 @@
</div> </div>
<div class="flex items-start gap-2 mb-2"> <div class="flex items-start gap-2 mb-2">
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div> <div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button> <button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit labels"><i class="ph ph-tag text-sm"></i></button>
</div> </div>
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2"> <div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2">
<div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div> <div id="gitea-labels-warning" class="hidden mb-2 px-2 py-1 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
@@ -171,7 +171,7 @@
<!-- Metadata row: assignees + milestone + due date with inline editor --> <!-- Metadata row: assignees + milestone + due date with inline editor -->
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div> <div id="gitea-detail-meta" class="flex flex-wrap gap-x-3 gap-y-1 flex-1 text-[10px] font-pixel text-t-dim"></div>
<button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" title="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button> <button id="gitea-meta-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit assignees, milestone, due date"><i class="ph ph-sliders-horizontal text-sm"></i></button>
</div> </div>
<div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2"> <div id="gitea-meta-editor" class="hidden border border-t-border bg-t-surface p-3 mb-2">
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@@ -192,7 +192,7 @@
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span> <span class="font-pixel text-[9px] text-t-dim uppercase block mb-1"><i class="ph ph-calendar text-xs"></i> due date</span>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono"> <input id="gitea-meta-due" type="date" class="flex-1 bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
<button id="gitea-meta-due-clear" class="toolbar-btn" title="Clear due date"><i class="ph ph-x text-sm"></i></button> <button id="gitea-meta-due-clear" class="toolbar-btn" data-tip="Clear due date"><i class="ph ph-x text-sm"></i></button>
</div> </div>
</div> </div>
</div> </div>
@@ -213,7 +213,7 @@
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4"> <div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
<div id="gitea-body-display" class="flex items-start gap-2"> <div id="gitea-body-display" class="flex items-start gap-2">
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div> <div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button> <button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" data-tip="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
</div> </div>
<div id="gitea-body-edit" class="hidden"> <div id="gitea-body-edit" class="hidden">
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea> <textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
+16 -6
View File
@@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) {
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span> <span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono"> <input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label> </label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<div class="flex items-center gap-2 pt-1"> <div class="flex items-center gap-2 pt-1">
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button> <button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button> <button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
@@ -150,12 +154,12 @@ function renderAccountsList() {
<i class="ph ph-git-pull-request text-t-accent text-base"></i> <i class="ph ph-git-pull-request text-t-accent text-base"></i>
<div class="gitea-account-info"> <div class="gitea-account-info">
<div class="gitea-account-name">${escapeHtml(a.name)}</div> <div class="gitea-account-name">${escapeHtml(a.name)}</div>
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div> <div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
</div> </div>
<div class="gitea-account-actions"> <div class="gitea-account-actions">
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button> <button class="toolbar-btn gitea-edit-btn" data-tip="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button> <button class="toolbar-btn gitea-test-btn" data-tip="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button> <button class="toolbar-btn gitea-del-btn" data-tip="Delete"><i class="ph ph-trash text-sm"></i></button>
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -201,6 +205,7 @@ function resetAccountForm() {
$('gitea-acc-name').value = ''; $('gitea-acc-name').value = '';
$('gitea-acc-baseurl').value = ''; $('gitea-acc-baseurl').value = '';
$('gitea-acc-token').value = ''; $('gitea-acc-token').value = '';
$('gitea-acc-default-repo').value = '';
setAccountStatus(''); setAccountStatus('');
$('gitea-tok-hint').textContent = ''; $('gitea-tok-hint').textContent = '';
} }
@@ -214,6 +219,7 @@ function editAccount(id) {
$('gitea-acc-name').value = acc.name; $('gitea-acc-name').value = acc.name;
$('gitea-acc-baseurl').value = acc.base_url; $('gitea-acc-baseurl').value = acc.base_url;
$('gitea-acc-token').value = ''; $('gitea-acc-token').value = '';
$('gitea-acc-default-repo').value = acc.default_repo || '';
$('gitea-tok-hint').textContent = '(leave blank to keep existing)'; $('gitea-tok-hint').textContent = '(leave blank to keep existing)';
setAccountStatus(''); setAccountStatus('');
$('gitea-acc-name').focus(); $('gitea-acc-name').focus();
@@ -240,6 +246,7 @@ async function saveAccount(e) {
name: $('gitea-acc-name').value.trim(), name: $('gitea-acc-name').value.trim(),
base_url: $('gitea-acc-baseurl').value.trim(), base_url: $('gitea-acc-baseurl').value.trim(),
token: $('gitea-acc-token').value, token: $('gitea-acc-token').value,
default_repo: $('gitea-acc-default-repo').value.trim(),
}; };
if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) { if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) {
setAccountStatus('name, base url, and token are required', 'error'); setAccountStatus('name, base url, and token are required', 'error');
@@ -713,8 +720,9 @@ async function openCreateForm() {
$('gitea-create-form').classList.remove('hidden'); $('gitea-create-form').classList.remove('hidden');
$('gitea-create-form').classList.add('flex'); $('gitea-create-form').classList.add('flex');
// Reset form // Reset form — pre-fill default repo if configured
$('gitea-create-repo').value = ''; const currentAccount = accounts.find(a => a.id === currentAccountId);
$('gitea-create-repo').value = currentAccount?.default_repo || '';
$('gitea-create-title').value = ''; $('gitea-create-title').value = '';
$('gitea-create-body').value = ''; $('gitea-create-body').value = '';
setCreateStatus(''); setCreateStatus('');
@@ -766,6 +774,8 @@ async function openCreateForm() {
errBox.classList.remove('hidden'); errBox.classList.remove('hidden');
} }
} }
if (currentAccount?.default_repo) loadCreateRepoLabels();
} }
function updateRepoHint() { function updateRepoHint() {
+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"]
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* Shopping Plugin: backend.
*
* Thin forwarder from the client to the relay's /api/shopping*
* endpoints. The phone-side view is part of the relay UI; this
* plugin just pipes hyprcrd's push/pull through the configured
* relay credentials.
*
* Routes:
* GET /api/shopping — pull active list state from relay
* POST /api/shopping — push active card {uid,title,body}
* POST /api/shopping/check — (mostly phone-only; proxied for
* completeness/tests — relay will
* 401 for client-auth callers)
*/
function shoppingJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function shoppingRelay(): array {
$db = Database::connect();
return [
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
];
}
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
if (!in_array($method, ['GET', 'POST'], true)) return;
$r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) {
shoppingJson(['ok' => false, 'error' => 'relay not configured']);
}
$ch = curl_init(rtrim($r['url'], '/') . $route);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status ?: 502);
header('Content-Type: application/json');
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
exit;
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "shopping",
"version": "1.0.0",
"description": "Ship one shopping card at a time to the relay so it's checkable from your phone. hyprcrd stays the edit surface; phone only toggles.",
"frontend": {
"js": ["shopping.js"],
"css": ["shopping.css"]
},
"backend": "backend.php"
}
+6
View File
@@ -0,0 +1,6 @@
/* Shopping plugin styles. Most of the picker dialog leans on
existing Tailwind tokens (text-t-accent, border-t-border, etc.)
so there's little needed here — only things that can't be
expressed inline on dynamically-created nodes. */
.shopping-picker-overlay { font-family: var(--t-font-mono, inherit); }
+240
View File
@@ -0,0 +1,240 @@
// ── Shopping-list plugin (hyprcrd side) ──
//
// Tag a card with #einkauf or #shopping to make it eligible. Pick
// one as "active" via the Shopping header button; the active card's
// body is pushed to the relay on every sync.tick and on every
// cards.reloaded so edits land on the phone within seconds. Pulls
// phone-side toggles back and PATCHes the local card.
//
// No timestamp cursors — we diff the (uid, title, body) triple we
// last saw in sync with the relay, which is timezone-agnostic.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
const SYNCED_KEY = 'hyprcrd-shopping-synced';
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function hasEinkaufTag(card) {
const tags = (card.tags || '').split(',').map(s => s.trim());
return tags.includes('einkauf') || tags.includes('shopping');
}
function getActiveId() {
const v = localStorage.getItem(ACTIVE_KEY);
return v ? String(v) : null;
}
function setActiveId(id) {
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
else localStorage.setItem(ACTIVE_KEY, String(id));
localStorage.removeItem(SYNCED_KEY);
}
function getActiveCard() {
const id = getActiveId();
if (!id) return null;
const cards = window.state?.cards || [];
return cards.find(c => String(c.id) === id) || null;
}
function getSynced() {
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
}
function setSynced(uid, title, body) {
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
}
// ── Header button injection ──────────────────────────────────
function injectHeaderButton() {
if ($('shopping-btn')) return;
// Put it next to Calendar. The header's right-hand button row
// lives in `header > div:last-child > .flex.items-center.gap-3`.
const header = document.querySelector('header#main-header');
if (!header) return;
const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
const group = rightRow[rightRow.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'shopping-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-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
btn.addEventListener('click', openDialog);
// Insert as the first item of the right-hand group so the order
// reads Shopping, Calendar, Assets, Tasks, New.
group.insertBefore(btn, group.firstChild);
}
// ── Picker dialog ────────────────────────────────────────────
function openDialog() {
document.querySelector('.shopping-picker-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'shopping-picker-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:var(--t-overlay,rgba(0,0,0,0.6));z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;';
const panel = document.createElement('div');
panel.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 8px 32px rgba(0,0,0,0.6);width:100%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;';
panel.innerHTML = `
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
<i class="ph ph-shopping-cart text-t-accent text-base"></i>
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2>
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
Cards tagged with <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> can be set as the active list for the
phone. Only one is active at a time; its body is pushed to the relay on every sync
and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
</div>
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div>
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
<span id="shopping-picker-status" class="font-pixel text-[10px] text-t-dim flex-1"></span>
<button class="shopping-picker-cancel px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger"><span class="font-pixel text-[10px]">Close</span></button>
<button class="shopping-picker-save px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10"><span class="font-pixel text-[10px]">Save</span></button>
</div>
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const list = panel.querySelector('#shopping-picker-list');
const status = panel.querySelector('#shopping-picker-status');
const close = () => overlay.remove();
panel.querySelector('.shopping-picker-close').addEventListener('click', close);
panel.querySelector('.shopping-picker-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); }
});
const cards = window.state?.cards || [];
const candidates = cards.filter(hasEinkaufTag);
const currentActive = getActiveId();
let selectedId = currentActive;
if (!candidates.length) {
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
No cards tagged <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
and add one of those tags — it'll show up here.
</div>`;
} else {
list.innerHTML = candidates.map(c => {
const id = String(c.id);
const checked = id === selectedId ? 'checked' : '';
const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l));
const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length;
return `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;">
<div class="flex-1 min-w-0">
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
</div>
</label>`;
}).join('');
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
<span class="text-[11px] text-t-muted italic">(no active list)</span>
</label>`);
list.querySelectorAll('input[name="shopping-active"]').forEach(r => {
r.addEventListener('change', () => { selectedId = r.value || null; });
});
}
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
const prev = getActiveId();
setActiveId(selectedId);
status.textContent = 'saving…';
try {
if (selectedId) await pushActive();
else await clearRemote();
status.textContent = 'saved';
setTimeout(close, 400);
} catch (e) {
status.textContent = 'error: ' + (e?.message || e);
status.style.color = 'var(--t-danger)';
setActiveId(prev);
}
});
}
// ── Sync ─────────────────────────────────────────────────────
async function pushActive() {
const card = getActiveCard();
if (!card) return;
const uid = String(card.id);
const title = card.title || '';
const body = card.body || '';
const synced = getSynced();
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return;
const res = await hyprcrd.api('api/shopping', {
method: 'POST',
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
}
async function pullActive() {
const card = getActiveCard();
if (!card) return;
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
if (!res?.ok || !res.state) return;
const { card_uid, body, title } = res.state;
if (String(card_uid) !== String(card.id)) return;
const synced = getSynced();
if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
setSynced(card.id, card.title || '', card.body || '');
return;
}
// Relay differs from both the sync marker and the local card — phone
// toggled. Phone can only flip checkboxes (never edit text), so the
// relay body is authoritative for this tick.
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
setSynced(card.id, card.title || '', body || '');
await window._reloadAll?.();
}
async function clearRemote() {
await hyprcrd.api('api/shopping', {
method: 'POST',
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
localStorage.removeItem(SYNCED_KEY);
}
async function tick() {
if (!getActiveId()) return;
try { await pushActive(); } catch {}
try { await pullActive(); } catch {}
}
// ── Init ─────────────────────────────────────────────────────
function init() {
injectHeaderButton();
hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'shopping',
version: '1.0.0',
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
});
+3 -3
View File
@@ -28,11 +28,11 @@
<span class="font-pixel text-[10px]">image:</span> <span class="font-pixel text-[10px]">image:</span>
<input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;"> <input id="slideshow-ed-src" type="text" placeholder="/uploads/foo.png" class="bg-t-bg border border-t-border text-t-text text-xs font-mono" style="width:300px;padding:2px 8px;border-radius:3px;">
</label> </label>
<button id="slideshow-ed-gallery" class="toolbar-btn text-xs" title="Pick from gallery"><i class="ph ph-images text-sm"></i></button> <button id="slideshow-ed-gallery" class="toolbar-btn text-xs" data-tip="pick from gallery"><i class="ph ph-images text-sm"></i></button>
<button id="slideshow-ed-pick" class="toolbar-btn text-xs" title="Upload new image"><i class="ph ph-upload-simple text-sm"></i></button> <button id="slideshow-ed-pick" class="toolbar-btn text-xs" data-tip="upload new image"><i class="ph ph-upload-simple text-sm"></i></button>
<input type="file" id="slideshow-ed-file" accept="image/*" class="hidden"> <input type="file" id="slideshow-ed-file" accept="image/*" class="hidden">
<div class="w-px h-4 bg-t-border mx-1"></div> <div class="w-px h-4 bg-t-border mx-1"></div>
<button id="slideshow-ed-delete" class="toolbar-btn text-xs" title="Delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button> <button id="slideshow-ed-delete" class="toolbar-btn text-xs" data-tip="delete selected hotspot" disabled><i class="ph ph-trash text-sm"></i></button>
<div class="flex-1"></div> <div class="flex-1"></div>
<span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span> <span id="slideshow-ed-hint" class="font-pixel text-[9px] text-t-dim">drag on image to draw · click rect to select</span>
</div> </div>
+1 -1
View File
@@ -187,7 +187,7 @@ function renderImageSlide(slide) {
const rects = slide.hotspots.map((h, i) => { const rects = slide.hotspots.map((h, i) => {
const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`; const style = `left:${h.x}%;top:${h.y}%;width:${h.w}%;height:${h.h}%`;
const label = resolveTargetLabel(h.target) || h.target || ''; const label = resolveTargetLabel(h.target) || h.target || '';
return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" title="${escHtml(label)}"></div>`; return `<div class="slide-hotspot-live" data-hs="${i}" style="${style}" data-tip="${escHtml(label)}"></div>`;
}).join(''); }).join('');
viewerSlide.innerHTML = ` viewerSlide.innerHTML = `
<div class="slide-image-wrap"> <div class="slide-image-wrap">
+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);
});
});
+47
View File
@@ -184,3 +184,50 @@ describe('Board: Focus restoration after overlay close', () => {
expect(document.querySelector('#board .card-focused')).toExist(); expect(document.querySelector('#board .card-focused')).toExist();
}); });
}); });
describe('Board: new-card pulse', () => {
// Journal entries shouldn't get the 1× card-pulse-local flash —
// they're daily-log jottings, not "new thing" moments. We use
// title "9999-12-31" for the journal probe to ensure we can't
// collide with a real user entry; a control card with a
// __prefixed__ title verifies the pulse still fires for normal
// local creations.
it('locally-created journal-format cards skip the pulse', async () => {
const token = csrf();
const hJson = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
const hCsrf = { 'X-CSRF-Token': token };
let journal, control;
try {
journal = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '9999-12-31', body: '' }) }).then(r => r.json());
control = await fetch('api/cards', { method: 'POST', headers: hJson, body: JSON.stringify({ title: '__test_pulse_ctrl__', body: '' }) }).then(r => r.json());
expect(journal?.id).toExist();
expect(control?.id).toExist();
// Flag both as "just created locally" so renderBoard routes
// them through the local-pulse branch. The journal
// exemption then has to filter out the journal one.
window.state.recentlyCreatedIds.add(String(journal.id));
window.state.recentlyCreatedIds.add(String(control.id));
await window._reloadAll();
// Wait past the rAF tick so pulse classes have been
// applied, but well within the 900ms lifetime so we
// can still observe them.
await sleep(150);
const journalEl = document.querySelector(`.board-card[data-id="${journal.id}"]`);
const controlEl = document.querySelector(`.board-card[data-id="${control.id}"]`);
expect(journalEl).toExist();
expect(controlEl).toExist();
expect(journalEl.classList.contains('card-pulse-local')).toBe(false);
expect(controlEl.classList.contains('card-pulse-local')).toBe(true);
} finally {
// Clean up even on failure. Backup/restore would catch it
// eventually, but between tests we don't want a stray
// 9999-12-31 card floating around upsetting other suites.
if (journal?.id) await fetch(`api/cards/${journal.id}`, { method: 'DELETE', headers: hCsrf });
if (control?.id) await fetch(`api/cards/${control.id}`, { method: 'DELETE', headers: hCsrf });
await window._reloadAll?.();
}
});
});
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, $, click, sleep, waitFor, csrf } from './runner.js?v=4';
// Bookmarks plugin tests. The plugin injects a header button, decorates
// #bookmarks-tagged board cards, and opens a column-based overlay.
// Tests create throwaway cards and clean up in finally blocks.
const token = () => csrf();
const hJson = () => ({ 'Content-Type': 'application/json', 'X-CSRF-Token': token() });
const hDel = () => ({ 'X-CSRF-Token': token() });
function emptyBody() {
return JSON.stringify({ type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 }) + '\n\n#bookmarks';
}
function bodyWith(columns, columnWidth = 220) {
return JSON.stringify({ type: 'bookmarks', columns, columnWidth }) + '\n\n#bookmarks';
}
describe('Bookmarks: plugin registration', () => {
it('registers itself in window.hyprcrd.plugins', () => {
const entry = (window.hyprcrd?.plugins || []).find(p => p?.name === 'bookmarks');
expect(entry).toExist();
});
});
describe('Bookmarks: header button', () => {
it('injects a Bookmarks button into the header', () => {
const btn = $('#bookmarks-btn');
expect(btn).toExist();
expect(btn.closest('#main-header')).toExist();
});
});
describe('Bookmarks: board card rendering', () => {
it('decorates #bookmarks cards with bm-card class', async () => {
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_deco__', body: emptyBody(), color: '#fb923c' }) }).then(r => r.json());
expect(card?.id).toExist();
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
expect(el).toExist();
expect(el.classList.contains('bm-card')).toBe(true);
expect(el.querySelector('.bm-card-icon')).toExist();
expect(el.querySelector('.bm-card-title').textContent).toContain('__test_bm_deco__');
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: overlay', () => {
it('opens on click and shows columns from the card body', async () => {
const cols = [
{ name: 'TestCol', items: [{ url: 'https://example.com', title: 'Example', favicon: '' }] }
];
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_overlay__', body: bodyWith(cols) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
expect(el).toExist();
click(el);
await sleep(150);
const overlay = $('#bookmarks-overlay');
expect(overlay.classList.contains('open')).toBe(true);
expect($('bookmarks-title').textContent).toContain('__test_bm_overlay__');
// Column rendered
const colName = document.querySelector('.bm-col-name');
expect(colName).toExist();
expect(colName.textContent).toBe('TestCol');
// Item rendered
const item = document.querySelector('.bm-item');
expect(item).toExist();
expect(item.textContent).toContain('Example');
// Close
click($('bookmarks-close'));
await sleep(100);
expect(overlay.classList.contains('open')).toBe(false);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: fetch-meta API', () => {
it('returns JSON with ok field for a valid URL', async () => {
const r = await fetch('api/bookmarks/fetch-meta?url=' + encodeURIComponent('https://example.com')).then(x => x.json());
expect(r.ok).toBe(true);
expect(typeof r.title).toBe('string');
// favicon is either a data: URI or empty string
expect(typeof r.favicon).toBe('string');
});
it('returns 400 for missing URL', async () => {
const r = await fetch('api/bookmarks/fetch-meta');
expect(r.status).toBe(400);
});
});
describe('Bookmarks: card links', () => {
it('renders card:ID items with a card icon', async () => {
let target, bm;
try {
target = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_target__', body: 'hello' }) }).then(r => r.json());
const cols = [{ name: 'Links', items: [{ url: `card:${target.id}`, title: '__test_bm_target__', favicon: '' }] }];
bm = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_cardlink__', body: bodyWith(cols) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${bm.id}"]`);
click(el);
await sleep(150);
const item = document.querySelector('.bm-item');
expect(item).toExist();
// Card links get a ph-card icon, not ph-globe
const cardIcon = item.querySelector('.ph-card');
expect(cardIcon).toExist();
click($('bookmarks-close'));
await sleep(80);
} finally {
for (const c of [target, bm]) {
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel() });
}
await window._reloadAll();
}
});
});
describe('Bookmarks: column width persistence', () => {
it('saves and restores columnWidth from card body', async () => {
const cols = [{ name: 'W', items: [] }];
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_width__', body: bodyWith(cols, 300) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el);
await sleep(150);
const slider = $('#bookmarks-col-width');
expect(slider).toExist();
expect(parseInt(slider.value)).toBe(300);
click($('bookmarks-close'));
await sleep(80);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
+22 -1
View File
@@ -47,7 +47,7 @@ describe('Calendar', () => {
await sleep(200); await sleep(200);
click('#cal-today'); click('#cal-today');
await sleep(200); await sleep(200);
const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]); expect($('#cal-title').textContent).toContain(months[new Date().getMonth()]);
}); });
@@ -58,4 +58,25 @@ describe('Calendar', () => {
await sleep(150); await sleep(150);
expect($('#calendar')).toBeHidden(); expect($('#calendar')).toBeHidden();
}); });
it('preserves month across close/reopen', async () => {
// Open calendar and navigate forward 3 months
click('#calendar-btn');
await sleep(200);
click('#cal-next');
click('#cal-next');
click('#cal-next');
await sleep(200);
const navigatedTitle = $('#cal-title').textContent;
// Close
click('#cal-close');
await sleep(150);
expect($('#calendar')).toBeHidden();
// Reopen — must stay on the navigated month, not jump to today
click('#calendar-btn');
await sleep(200);
expect($('#cal-title').textContent).toBe(navigatedTitle);
});
}); });
+33 -9
View File
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
expect(doneBtn.textContent.toLowerCase()).toContain('mark done'); expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
click(doneBtn); click(doneBtn);
await sleep(300); // Poll until #done is persisted to the DB
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json()); let updated;
for (let i = 0; i < 40; i++) {
await sleep(100);
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
if ((updated[0]?.tags || '').includes('done')) break;
}
expect((updated[0].body || '').toLowerCase()).toContain('#done'); expect((updated[0].body || '').toLowerCase()).toContain('#done');
expect(updated[0].tags || '').toContain('done'); expect(updated[0].tags || '').toContain('done');
// Wait for the board to re-render with the updated card data
for (let i = 0; i < 20; i++) {
const card = window.state?.cards?.find(c => c.id == res.id);
if (card && (card.tags || '').includes('done')) break;
await sleep(100);
}
// Re-open menu — the entry should now flip to "Unmark done" // Re-open menu — the entry should now flip to "Unmark done"
rightClick(document.querySelector(`[data-id="${res.id}"]`)); rightClick(document.querySelector(`[data-id="${res.id}"]`));
await sleep(150); await sleep(200);
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0]; const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark'); expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
// Confirm the delete // Confirm the delete
const okBtn = document.getElementById('dialog-ok'); const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn); if (okBtn) click(okBtn);
await sleep(500);
} }
// All three cards should be gone // Poll until all three cards are gone (group delete = N serial DELETEs)
const remaining = await fetch('api/cards').then(r => r.json()); let remaining;
for (let i = 0; i < 40; i++) {
await sleep(100);
remaining = await fetch('api/cards').then(r => r.json());
const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id);
if (!stillThere) break;
}
expect(remaining.some(c => c.id === parent.id)).toBe(false); expect(remaining.some(c => c.id === parent.id)).toBe(false);
expect(remaining.some(c => c.id === child1.id)).toBe(false); expect(remaining.some(c => c.id === child1.id)).toBe(false);
expect(remaining.some(c => c.id === child2.id)).toBe(false); expect(remaining.some(c => c.id === child2.id)).toBe(false);
@@ -375,11 +392,18 @@ describe('Card: Group Delete', () => {
if (dialog?.classList.contains('open')) { if (dialog?.classList.contains('open')) {
const okBtn = document.getElementById('dialog-ok'); const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn); if (okBtn) click(okBtn);
await sleep(500);
} }
// Confirm both are gone // Poll until deletion lands (can take a while on slow machines with
let cards = await fetch('api/cards').then(r => r.json()); // confirm-dialog + two DELETE calls chained).
let cards;
const start = Date.now();
while (Date.now() - start < 4000) {
cards = await fetch('api/cards').then(r => r.json());
const stillThere = cards.some(c => c.id === parent.id || c.id === child.id);
if (!stillThere) break;
await sleep(100);
}
expect(cards.some(c => c.id === parent.id)).toBe(false); expect(cards.some(c => c.id === parent.id)).toBe(false);
expect(cards.some(c => c.id === child.id)).toBe(false); expect(cards.some(c => c.id === child.id)).toBe(false);
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, click, $, sleep, waitFor, csrf } 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-caret 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 caret = $('#chat-header-back-caret');
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(caret.classList.contains('hidden')).toBe(false);
if (listShown) expect(caret.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);
// Back caret is only for thread view — not needed when pick has
// its own dedicated back button.
expect($('#chat-header-back-caret').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');
// Every entry carries kind + key so the UI can route 1:1 vs. room.
for (const c of res.conversations) {
expect(c.kind === 'peer' || c.kind === 'room').toBe(true);
expect(typeof c.key).toBe('string');
}
});
it('/api/chat/messages without peer or room_uid 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);
});
it('/api/chat/messages with unknown room returns empty array', async () => {
const res = await fetch('api/chat/messages?room_uid=__no_such_room__')
.then(r => r.json());
expect(Array.isArray(res.messages)).toBe(true);
expect(res.messages.length).toBe(0);
});
it('/api/chat/rooms returns rooms array', async () => {
const res = await fetch('api/chat/rooms').then(r => r.json());
expect(res.ok).toBe(true);
expect(Array.isArray(res.rooms)).toBe(true);
});
it('/api/chat/thread-settings round-trips disposable flag', async () => {
const peer = '__test_disposable_flip__';
const setFlag = (v) => fetch('api/chat/thread-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ peer, disposable: v }),
}).then(r => r.json());
// Reset from any leftover state (previous runs leave the row
// behind — restore only rolls cards/buckets, not plugin tables).
const reset = await setFlag(false);
expect(reset.disposable).toBe(0);
const set = await setFlag(true);
expect(set.disposable).toBe(1);
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
.then(r => r.json());
expect(after.disposable).toBe(1);
// Leave a clean slate for the next run.
await setFlag(false);
});
});
describe('Chat: group creation UI', () => {
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'));
}
};
it('pick view has 1:1 / group mode switch', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
await sleep(80);
click('#chat-new-btn');
await sleep(60);
const mode = document.querySelectorAll('.chat-pick-mode-btn');
expect(mode.length).toBe(2);
// Toggling to group reveals the name input and action bar.
const groupBtn = document.querySelector('.chat-pick-mode-btn[data-mode="group"]');
click(groupBtn);
await sleep(30);
expect($('#chat-group-name').classList.contains('hidden')).toBe(false);
expect($('#chat-group-actions').classList.contains('hidden')).toBe(false);
await ensureFlyoutClosed();
});
it('room-info view element exists', () => {
expect($('#chat-room-info-view')).toExist();
expect($('#chat-room-info-back')).toExist();
expect($('#chat-room-info-body')).toExist();
});
});
+38
View File
@@ -72,3 +72,41 @@ describe('Editor: Card Create', () => {
expect(document.querySelector(`[data-id="${res.id}"]`)).toExist(); expect(document.querySelector(`[data-id="${res.id}"]`)).toExist();
}); });
}); });
describe('Editor: new-appointment bucket default', () => {
// New appointments (body pre-seeded with #termin / #geburtstag from
// the calendar day-popup template) must not inherit the currently
// active real bucket — they belong to a calendar. Regular new
// cards keep inheriting so the user doesn't have to re-pick.
afterEach(async () => {
// Close any open modal and restore the runner's baseline bucket.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(80);
window.state.activeBucket = 'all';
});
it('#termin body forces empty bucket; plain body inherits active', async () => {
const buckets = await fetch('api/buckets').then(r => r.json());
if (!Array.isArray(buckets) || !buckets.length) return; // nothing to inherit from
const b = buckets[0];
window.state.activeBucket = b.id;
// Appointment-flavoured body → inp-bucket stays empty.
window._openModal({ id: null, title: '9999-01-01 — probe', body: '#termin', color: '#fb923c', bucket_id: null });
await sleep(80);
const apptBucket = $('#inp-bucket').value;
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(120);
// Plain body → inp-bucket inherits the active bucket id.
window._openModal({ id: null, title: '__test_inherit__', body: '', color: '#38bdf8', bucket_id: null });
await sleep(80);
const regBucket = $('#inp-bucket').value;
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(120);
expect(apptBucket).toBe('');
expect(regBucket).toBe(String(b.id));
});
});
-2
View File
@@ -14,8 +14,6 @@ describe('RRULE: Data Model', () => {
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag')); const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
for (const b of birthdays) { for (const b of birthdays) {
expect(b.tags).toContain('geburtstag'); expect(b.tags).toContain('geburtstag');
// Birthday titles start with date
expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/);
} }
}); });
+138 -3
View File
@@ -5,7 +5,7 @@
// Backup is created before and auto-restored after every run. // Backup is created before and auto-restored after every run.
import { getCsrf } from '../../js/modules/util.js'; import { getCsrf } from '../../js/modules/util.js';
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js'; import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/sync.js';
// Suites live on window so two runner.js instances (one cached and loaded // Suites live on window so two runner.js instances (one cached and loaded
// by test files via bare imports, one fresh and loaded by loader.js with // by test files via bare imports, one fresh and loaded by loader.js with
@@ -170,7 +170,73 @@ export function csrf() {
return getCsrf(); return getCsrf();
} }
// Snapshot the view-affecting bits of window.state so we can force a
// neutral baseline (normal board, no filter, manual sort, no hides)
// for the test run and restore the user's view afterwards. Tests were
// flaky when the runner fired while the board was in super-view: DOM
// selectors like `.card[data-id]` don't match `.superview-item`, so
// multi-select and due-date tests read wrong initial values.
export function snapshotViewState() {
const s = window.state;
if (!s) return null;
return {
superView: s.superView,
activeBucket: s.activeBucket,
activeTags: [...(s.activeTags || [])],
activeGroup: s.activeGroup,
sortMode: s.sortMode,
hideTermine: s.hideTermine,
hideDone: s.hideDone,
hideTagged: s.hideTagged,
boardSplitOpen: s.boardSplitOpen,
};
}
export function resetViewState() {
const s = window.state;
if (!s) return;
s.superView = false;
s.activeBucket = 'all';
s.activeTags = [];
s.activeGroup = null;
s.sortMode = 'manual';
s.hideTermine = false;
s.hideDone = false;
s.hideTagged = false;
s.boardSplitOpen = false;
s.selectedIds?.clear?.();
// Sync super-view toggle visuals with the reset state so tests that
// inspect the button see the correct pose.
const btn = document.getElementById('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-list text-sm';
btn?.classList.remove('active');
window._renderBoard?.();
}
export function restoreViewState(snap) {
const s = window.state;
if (!s || !snap) return;
Object.assign(s, {
superView: snap.superView,
activeBucket: snap.activeBucket,
activeTags: snap.activeTags,
activeGroup: snap.activeGroup,
sortMode: snap.sortMode,
hideTermine: snap.hideTermine,
hideDone: snap.hideDone,
hideTagged: snap.hideTagged,
boardSplitOpen: snap.boardSplitOpen,
});
const btn = document.getElementById('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = `ph ${snap.superView ? 'ph-squares-four' : 'ph-list'} text-sm`;
btn?.classList.toggle('active', !!snap.superView);
window._renderBoard?.();
}
export async function closeAll() { export async function closeAll() {
document.getElementById('hterm')?.classList.add('hidden');
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) { for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el?.classList.contains('open')) el.classList.remove('open'); if (el?.classList.contains('open')) el.classList.remove('open');
@@ -180,6 +246,7 @@ export async function closeAll() {
document.querySelector('.bucket-picker')?.remove(); document.querySelector('.bucket-picker')?.remove();
document.querySelector('.orphan-lightbox')?.remove(); document.querySelector('.orphan-lightbox')?.remove();
document.getElementById('bucket-super-popover')?.remove(); document.getElementById('bucket-super-popover')?.remove();
document.getElementById('asset-picker-flyout')?.remove();
document.getElementById('gitea-overlay')?.classList.remove('open'); document.getElementById('gitea-overlay')?.classList.remove('open');
// Clear multi-select // Clear multi-select
document.querySelectorAll('.card-selected').forEach(el => { document.querySelectorAll('.card-selected').forEach(el => {
@@ -199,6 +266,37 @@ async function createBackup() {
backupBlob = await res.blob(); backupBlob = await res.blob();
} }
// Purge leftover test artifacts from previous failed runs.
// Tests use `__prefix__` naming; anything matching gets deleted before
// the baseline backup so it doesn't get restored after the run either.
async function purgeTestLeftovers() {
const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf();
let purged = { cards: 0, buckets: 0 };
try {
const cards = await fetch('api/cards').then(r => r.json());
const testCards = cards.filter(c => /^__.*__/.test(c.title || ''));
for (const c of testCards) {
await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
purged.cards++;
}
} catch {}
try {
const buckets = await fetch('api/buckets').then(r => r.json());
const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || ''));
for (const b of testBuckets) {
await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
purged.buckets++;
}
} catch {}
// Also clear any leftover editor drafts so the restore prompt doesn't fire
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
}
} catch {}
return purged;
}
async function restoreBackup() { async function restoreBackup() {
if (!backupBlob) return; if (!backupBlob) return;
const form = new FormData(); const form = new FormData();
@@ -396,10 +494,18 @@ async function run() {
const output = panel.querySelector('#test-output'); const output = panel.querySelector('#test-output');
const summary = panel.querySelector('#test-summary'); const summary = panel.querySelector('#test-summary');
// 1. Create backup // 1. Purge leftover test artifacts from any previous failed run
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>'; output.innerHTML = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
summary.textContent = ''; summary.textContent = '';
await sleep(50); await sleep(50);
const purged = await purgeTestLeftovers();
if (purged.cards || purged.buckets) {
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
}
// 2. Create backup (clean baseline)
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
await sleep(50);
try { try {
await createBackup(); await createBackup();
} catch (e) { } catch (e) {
@@ -411,6 +517,15 @@ async function run() {
stopBackgroundSync(); stopBackgroundSync();
window._testRunning = true; window._testRunning = true;
// 2b. Snapshot + reset view state. Tests assume a normal board with
// no filters / super-view off / manual sort — if the user left the
// app in super-view or with a bucket filter active, assertions on
// .card[data-id], card count, etc. read wrong initial values.
const viewSnap = snapshotViewState();
await closeAll();
resetViewState();
await sleep(50);
// 3. Hide panel so tests run against visible UI // 3. Hide panel so tests run against visible UI
panel.style.display = 'none'; panel.style.display = 'none';
await sleep(100); await sleep(100);
@@ -435,6 +550,11 @@ async function run() {
await window._reloadAll?.(); await window._reloadAll?.();
await sleep(200); await sleep(200);
} catch {} } catch {}
// Re-reset the view in case a failing test left it in super-view,
// a bucket filter, or a sorted mode — otherwise the retry runs
// against the same wrong baseline that flaked it the first time.
resetViewState();
await sleep(50);
const retryResults = await runAll(failedKeys); const retryResults = await runAll(failedKeys);
const retryMap = new Map(); const retryMap = new Map();
for (const sr of retryResults) { for (const sr of retryResults) {
@@ -467,6 +587,21 @@ async function run() {
await sleep(200); await sleep(200);
} catch {} } catch {}
// 6a. Purge any editor-draft entries the tests wrote while poking
// the modal. Leaving them around means the next card open pops
// "unsaved draft from 0 min ago — restore?" to the user, which
// is noise since the tests weren't real edits of their cards.
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
}
} catch {}
// 6b. Put the view back the way the user left it (super-view, sort,
// filters, …) so launching the test runner doesn't silently wipe
// their working context.
restoreViewState(viewSnap);
// 7. Resume background sync (delay to avoid immediate re-sync creating duplicates) // 7. Resume background sync (delay to avoid immediate re-sync creating duplicates)
window._testRunning = false; window._testRunning = false;
setTimeout(() => startBackgroundSync(), 60000); setTimeout(() => startBackgroundSync(), 60000);
+122
View File
@@ -44,3 +44,125 @@ describe('Search', () => {
expect($('#search')).toBeHidden(); expect($('#search')).toBeHidden();
}); });
}); });
describe('Search: saved searches', () => {
const SAVED_KEY = 'hyprcrd-saved-searches';
afterEach(async () => {
// Close overlays and restore localStorage to avoid cross-test pollution.
document.querySelector('#search-saved-popup')?.classList.add('hidden');
await closeAll();
});
it('bookmark button and popup exist', () => {
expect($('#search-saved-btn')).toExist();
expect($('#search-saved-popup')).toExist();
expect($('#search-saved-popup').classList.contains('hidden')).toBe(true);
});
it('clicking bookmark opens the popup', async () => {
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(40);
expect($('#search-saved-popup').classList.contains('hidden')).toBe(false);
});
it('saves and recalls a search', async () => {
// Seed localStorage directly to avoid UI coupling for the save path.
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_search_probe__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe, ...before]));
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(60);
const popup = $('#search-saved-popup');
const rows = popup.querySelectorAll('.search-saved-row');
expect(rows.length).toBeGreaterThan(0);
// First row should be our probe.
expect(rows[0].textContent).toContain(probe);
// Click the row → fills input.
click(rows[0]);
await sleep(40);
expect($('#search-input').value).toBe(probe);
} finally {
// Restore original saved searches.
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('deletes a saved search', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_del__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe]));
click('#search-btn');
await sleep(100);
click('#search-saved-btn');
await sleep(60);
const delBtn = $('#search-saved-popup').querySelector('.search-saved-del');
expect(delBtn).toExist();
click(delBtn);
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
expect(after.includes(probe)).toBe(false);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('save-current button adds input value', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_add_' + Date.now() + '__';
try {
click('#search-btn');
await sleep(100);
const inp = $('#search-input');
inp.value = probe;
inp.dispatchEvent(new Event('input', { bubbles: true }));
click('#search-saved-btn');
await sleep(40);
click('#search-saved-add');
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
expect(after.includes(probe)).toBe(true);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
it('does not add duplicates', async () => {
const before = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const probe = '__test_saved_dupe__';
try {
localStorage.setItem(SAVED_KEY, JSON.stringify([probe]));
click('#search-btn');
await sleep(100);
const inp = $('#search-input');
inp.value = probe;
inp.dispatchEvent(new Event('input', { bubbles: true }));
click('#search-saved-btn');
await sleep(40);
click('#search-saved-add');
await sleep(40);
const after = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]');
const count = after.filter(q => q === probe).length;
expect(count).toBe(1);
} finally {
localStorage.setItem(SAVED_KEY, JSON.stringify(before));
}
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
// Shopping-plugin tests. Everything ships through the shopping plugin
// (backend.php registers /api/shopping*, shopping.js injects the
// header button and the picker dialog on load). Without a configured
// relay the backend still returns {ok:false,error:"relay not
// configured"} so we can assert shape.
describe('Shopping: plugin registration', () => {
it('registers itself in window.hyprcrd.plugins', () => {
const entries = window.hyprcrd?.plugins || [];
const entry = entries.find(p => p?.name === 'shopping');
expect(entry).toExist();
});
});
describe('Shopping: API proxy', () => {
it('GET /api/shopping returns an ok field', async () => {
const r = await fetch('api/shopping').then(x => x.json());
expect(typeof r.ok).toBe('boolean');
});
it('POST /api/shopping/check returns a JSON object', async () => {
const r = await fetch('api/shopping/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ line: '- [ ] nope', checked: true }),
}).then(x => x.json());
expect(r).toExist();
// The check endpoint is session-auth on the relay (phone-only),
// so hitting it via the client proxy returns an `error` field
// from the relay's unauthorized branch. Without a configured
// relay, it's `ok:false`. Either way it's a JSON object.
expect(typeof r.ok === 'boolean' || typeof r.error === 'string').toBe(true);
});
});
describe('Shopping: header button', () => {
it('Shopping button is injected into the header', () => {
const btn = $('#shopping-btn');
expect(btn).toExist();
// Lives inside the main header, not bolted onto the body.
expect(btn.closest('#main-header')).toExist();
});
it('clicking opens the picker dialog with save/close controls', async () => {
document.querySelector('.shopping-picker-overlay')?.remove();
click('#shopping-btn');
await sleep(60);
const overlay = document.querySelector('.shopping-picker-overlay');
expect(overlay).toExist();
expect(overlay.querySelector('.shopping-picker-save')).toExist();
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40);
expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
});
});
describe('Shopping: picker tag matching', () => {
// Creates one card per trigger tag + one untagged control, then
// opens the picker and asserts the list. Both #einkauf and
// #shopping must qualify; untagged card must not.
it('lists both #einkauf and #shopping cards, excludes others', async () => {
const token = csrf();
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
const hDel = { 'X-CSRF-Token': token };
let eink, shop, other;
try {
eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json());
shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json());
other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json());
await window._reloadAll();
// Close any leftover overlay, then open the picker fresh.
document.querySelector('.shopping-picker-overlay')?.remove();
click('#shopping-btn');
await sleep(80);
const overlay = document.querySelector('.shopping-picker-overlay');
expect(overlay).toExist();
const listText = overlay.querySelector('#shopping-picker-list').textContent;
expect(listText).toContain('__test_shop_einkauf__');
expect(listText).toContain('__test_shop_shopping__');
// Non-tagged control must be absent.
const controlShown = listText.includes('__test_shop_control__');
expect(controlShown).toBe(false);
// Picker always offers a "no active list" option so the
// user can stop syncing without leaving the dialog.
const radios = overlay.querySelectorAll('input[name="shopping-active"]');
const hasEmpty = [...radios].some(r => r.value === '');
expect(hasEmpty).toBe(true);
overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40);
} finally {
for (const c of [eink, shop, other]) {
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel });
}
await window._reloadAll();
}
});
});
+28 -8
View File
@@ -180,7 +180,7 @@ describe('Super-buckets: Bar rendering', () => {
expect(superTile.textContent).toContain('__sb_active_child__'); expect(superTile.textContent).toContain('__sb_active_child__');
}); });
it('clicking the super-tile opens a popover with parent + child entries', async () => { it('clicking the caret opens a popover with parent + child entries', async () => {
const parent = await makeBucket('__sb_pop_parent__'); const parent = await makeBucket('__sb_pop_parent__');
const child1 = await makeBucket('__sb_pop_child1__'); const child1 = await makeBucket('__sb_pop_child1__');
const child2 = await makeBucket('__sb_pop_child2__'); const child2 = await makeBucket('__sb_pop_child2__');
@@ -191,15 +191,15 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`); const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return; if (!superTile) return;
click(superTile); const caret = superTile.querySelector('[data-super-caret]');
expect(caret).toExist();
click(caret);
await sleep(100); await sleep(100);
const popover = document.getElementById('bucket-super-popover'); const popover = document.getElementById('bucket-super-popover');
expect(popover).toExist(); expect(popover).toExist();
// Parent + 2 children = 3 entries
const entries = popover.querySelectorAll('[data-bucket]'); const entries = popover.querySelectorAll('[data-bucket]');
expect(entries.length).toBe(3); expect(entries.length).toBe(3);
// All three names appear
const text = popover.textContent; const text = popover.textContent;
expect(text).toContain('__sb_pop_parent__'); expect(text).toContain('__sb_pop_parent__');
expect(text).toContain('__sb_pop_child1__'); expect(text).toContain('__sb_pop_child1__');
@@ -215,7 +215,8 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`); const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return; if (!superTile) return;
click(superTile); const caret = superTile.querySelector('[data-super-caret]');
click(caret);
await sleep(100); await sleep(100);
const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`); const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
@@ -226,7 +227,25 @@ describe('Super-buckets: Bar rendering', () => {
expect(String(state.activeBucket)).toBe(String(child.id)); expect(String(state.activeBucket)).toBe(String(child.id));
}); });
it('clicking the super-tile twice toggles the popover closed', async () => { it('clicking the super-tile activates the shown bucket', async () => {
const parent = await makeBucket('__sb_direct_parent__');
const child = await makeBucket('__sb_direct_child__');
await patchBucket(child.id, { parent_id: parent.id });
await window._reloadAll?.();
await sleep(200);
state.activeBucket = 'all';
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return;
click(superTile);
await sleep(150);
const shownId = superTile.dataset.superShown;
expect(String(state.activeBucket)).toBe(String(shownId));
expect(document.getElementById('bucket-super-popover')).toBe(null);
});
it('clicking the caret twice toggles the popover closed', async () => {
const parent = await makeBucket('__sb_toggle_parent__'); const parent = await makeBucket('__sb_toggle_parent__');
const child = await makeBucket('__sb_toggle_child__'); const child = await makeBucket('__sb_toggle_child__');
await patchBucket(child.id, { parent_id: parent.id }); await patchBucket(child.id, { parent_id: parent.id });
@@ -235,10 +254,11 @@ describe('Super-buckets: Bar rendering', () => {
const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`); const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
if (!superTile) return; if (!superTile) return;
click(superTile); const caret = superTile.querySelector('[data-super-caret]');
click(caret);
await sleep(100); await sleep(100);
expect(document.getElementById('bucket-super-popover')).toExist(); expect(document.getElementById('bucket-super-popover')).toExist();
click(superTile); click(caret);
await sleep(100); await sleep(100);
expect(document.getElementById('bucket-super-popover')).toBe(null); expect(document.getElementById('bucket-super-popover')).toBe(null);
}); });
+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');
});
});
+6
View File
@@ -39,6 +39,7 @@ const FILES = [
'./editor-toolbar.test.js', './editor-toolbar.test.js',
'./line-numbers.test.js', './line-numbers.test.js',
'./canvas.test.js', './canvas.test.js',
'./tim.test.js',
'./snippets.test.js', './snippets.test.js',
'./image-fullscreen.test.js', './image-fullscreen.test.js',
'./markdown.test.js', './markdown.test.js',
@@ -50,7 +51,12 @@ const FILES = [
'./relay.test.js', './relay.test.js',
'./notifications.test.js', './notifications.test.js',
'./quick-capture.test.js', './quick-capture.test.js',
'./asset-picker.test.js',
'./terminal.test.js',
'./theme.test.js', './theme.test.js',
'./chat.test.js',
'./shopping.test.js',
'./bookmarks.test.js',
]; ];
// Await all imports so the runner can start in a deterministic state. // Await all imports so the runner can start in a deterministic state.
+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]);
});
});
});
+24 -9
View File
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
bodyEl.value = 'after edit'; bodyEl.value = 'after edit';
bodyEl.dispatchEvent(new Event('input', { bubbles: true })); bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
click('#modal-save-close'); click('#modal-save-close');
await sleep(400);
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); let card;
for (let i = 0; i < 40; i++) {
await sleep(100);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
if ((card?.body || '').includes('after edit')) break;
}
expect(card.body).toContain('after edit'); expect(card.body).toContain('after edit');
ctrlZ(); ctrlZ();
await sleep(400);
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'); expect(card.body).toContain('before edit');
}); });
@@ -153,11 +160,11 @@ describe('Undo: Editor Save', () => {
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id); let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
expect(card.due_date).toBe(null); expect(card.due_date).toBe(null);
// Undo should restore the due date // Undo should restore the due date. The undo PATCH +
// _reloadAll is async — poll state.cards instead of a fixed
// sleep so slow environments don't flake.
ctrlZ(); ctrlZ();
await sleep(400); card = await waitForStateCard(res.id, c => c.due_date === '2099-06-15', 3000);
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
expect(card.due_date).toBe('2099-06-15'); expect(card.due_date).toBe('2099-06-15');
}); });
}); });
@@ -350,7 +357,15 @@ describe('Undo: No-Op When Empty', () => {
ctrlZ(); ctrlZ();
await sleep(200); await sleep(200);
} }
// Now Ctrl+Z should be harmless // Wait for state to settle — previous undo operations can still be
// resurrecting cards asynchronously after canUndo() has flipped to
// false. Poll until two consecutive fetches agree on card count.
let prev = -1, stable = 0;
for (let i = 0; i < 30 && stable < 2; i++) {
const cards = await fetch('api/cards').then(r => r.json());
if (cards.length === prev) stable++; else { stable = 0; prev = cards.length; }
if (stable < 2) await sleep(100);
}
const cardsBefore = await fetch('api/cards').then(r => r.json()); const cardsBefore = await fetch('api/cards').then(r => r.json());
ctrlZ(); ctrlZ();
await sleep(200); await sleep(200);
+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 A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
share one relay instance; each one has their own login, their own client share one relay instance; each one has their own login, their own client
encryption secret, and their own inbox. Cards (and in the future: encryption secret, and their own inbox. Cards and encrypted chat
encrypted chat messages) can be sent **between tenants** on the same messages can be sent **between tenants** on the same relay.
relay.
The relay is a single-file PHP app — drop the directory into any PHP The relay is a single-file PHP app — drop the directory into any PHP
webspace and it works. webspace and it works.
@@ -55,9 +54,9 @@ The admin only ever touches secrets #1 and #3. The user only ever touches secret
The relay **does** see plaintext briefly during the cross-tenant hop The relay **does** see plaintext briefly during the cross-tenant hop
(steps 2–3 and 5–6). This is the same trust model as email: you trust (steps 2–3 and 5–6). This is the same trust model as email: you trust
the relay operator. **Strict end-to-end encryption** for the planned the relay operator. Chat between tenants is shipped (via the chat
chat feature is on the roadmap and will require a per-pair key exchange plugin with delivery + read receipts). **Strict end-to-end encryption**
(Curve25519/ECDH) — this is documented as future work in `index.php`. with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
For at-rest data, all sensitive values are encrypted with the master For at-rest data, all sensitive values are encrypted with the master
key, so a stolen DB without the master key file gives an attacker key, so a stolen DB without the master key file gives an attacker
@@ -155,7 +154,7 @@ incoming messages.
### 6. Send a card to another tenant ### 6. Send a card to another tenant
(currently in the relay UI; hyprcrd-side recipient picker is coming) (in the relay UI compose tab)
In the relay UI **compose** tab: In the relay UI **compose** tab:
- Pick a recipient from the dropdown (defaults to yourself) - Pick a recipient from the dropdown (defaults to yourself)
@@ -166,8 +165,7 @@ The recipient will see it on their next hyprcrd poll, materialized as a
card in their board. card in their board.
You can also right-click a card in hyprcrd and pick **push to relay** — You can also right-click a card in hyprcrd and pick **push to relay** —
this currently sends to your own inbox; recipient picker on the hyprcrd this sends to your own inbox (or pick a recipient in the relay UI).
side is on the TODO.
## Data lifetime ## Data lifetime
@@ -226,6 +224,10 @@ All endpoints under `/api/`. JSON in/out.
| GET | `/api/me` | — | Current tenant info | | GET | `/api/me` | — | Current tenant info |
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) | | GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key | | POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
| GET | `/api/messages/inbox` | — | Read own inbox messages (decrypted for display) |
| DELETE | `/api/messages/{id}` | — | Delete a message from own inbox |
| GET | `/api/chat/sent` | — | Sent chat messages with delivery/read status |
| POST | `/api/chat/seen` | `{ids: [...]}` | Mark chat messages as seen (read receipts) |
### Admin only (session + `is_admin`) ### Admin only (session + `is_admin`)
@@ -302,8 +304,7 @@ use the same code path.
- A compromised relay machine = compromised messages. Use HTTPS, keep - A compromised relay machine = compromised messages. Use HTTPS, keep
PHP up to date, restrict shell access. PHP up to date, restrict shell access.
**Future work for stronger guarantees** (planned for the encrypted chat **Future work for stronger guarantees** (roadmap for strict E2E):
feature):
- Per-tenant Curve25519 key pair stored in the DB (public key plus - Per-tenant Curve25519 key pair stored in the DB (public key plus
master-key-encrypted private key, or even private key never on the master-key-encrypted private key, or even private key never on the
server) server)
@@ -522,7 +523,9 @@ relay/
│ └── routes/ │ └── routes/
│ ├── public.php # /api/status, /setup, /login, /logout, /me │ ├── public.php # /api/status, /setup, /login, /logout, /me
│ ├── admin.php # /api/users CRUD, /api/tenants directory │ ├── admin.php # /api/users CRUD, /api/tenants directory
│ └── messages.php # /api/messages, /api/attachments, cross-tenant routing │ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
├── ui/ ├── ui/
│ ├── layout.php # HTML shell + view containers │ ├── layout.php # HTML shell + view containers
│ ├── style.css # all CSS (including responsive media queries) │ ├── style.css # all CSS (including responsive media queries)
+97 -2
View File
@@ -189,8 +189,79 @@ $chatColNames = array_column($chatCols, 'name');
if (!in_array('seen', $chatColNames)) { if (!in_array('seen', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
} }
// Group chat rooms: room_uid (nullable — set for group messages, NULL for
// 1:1). recipient_id stays non-null for 1:1; group rooms resolve recipients
// via chat_room_members. `persistent` controls retention: 1 = keep in the
// long-term store so multi-device / late-joiners can pull history, 0 = old
// "disposable" behaviour (prune after fetch window). 1:1 defaults to 1;
// group messages are always 1.
if (!in_array('room_uid', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN room_uid TEXT');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)');
}
if (!in_array('persistent', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN persistent INTEGER NOT NULL DEFAULT 1');
}
// Sync-notification cleanup: the bot=1 chat messages turned out to clutter
// the chat thread. The feature moved to its own `relay_sync_events` queue
// (see below). Drop any leftover bot rows so they stop showing up in
// users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs
// a full table rebuild — not worth it for an ignored nullable column).
if (in_array('bot', $chatColNames)) {
$db->exec('DELETE FROM chat_messages WHERE bot = 1');
}
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
// Chat rooms: named group threads. Owner + membership mirrors collab
// buckets. Group messages live in chat_messages with room_uid set.
$db->exec('CREATE TABLE IF NOT EXISTS chat_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
owner_id INTEGER NOT NULL,
owner_username TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
room_uid TEXT NOT NULL,
tenant_id INTEGER NOT NULL,
username TEXT NOT NULL,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (room_uid, tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
// Shopping list: one active card per tenant, body mirrored from the
// client. Phone toggles checkboxes in-place via the session UI — no
// multi-list fan-out, no history, no attachments. hyprcrd remains the
// only surface that edits item text.
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
tenant_id INTEGER PRIMARY KEY,
card_uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "",
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
// Sync-event notifications: replaces the old bot-chat mechanism. When a
// client syncs a card they received, they POST here; the sender polls
// GET /api/sync-events and surfaces each as a transient toast. Rows are
// short-lived — we drop fetched ones after an hour, unfetched after a day.
$db->exec('CREATE TABLE IF NOT EXISTS relay_sync_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_id INTEGER NOT NULL,
sender_username TEXT NOT NULL,
body TEXT NOT NULL,
fetched INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_sync_events_recipient ON relay_sync_events(recipient_id, id)');
// Collaborative buckets: shared among multiple tenants. Each bucket has one // Collaborative buckets: shared among multiple tenants. Each bucket has one
// owner and a membership set. Buckets persist (not ephemeral) since they are // owner and a membership set. Buckets persist (not ephemeral) since they are
// the container for ongoing collaboration — cleanup only when deleted. // the container for ongoing collaboration — cleanup only when deleted.
@@ -232,6 +303,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
)'); )');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
// Collab attachments: binary assets referenced by collab cards (images,
// PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to
// a bucket — all members of the bucket can fetch. Filename is the
// hex-hash name that appears in card bodies as uploads/<name>.
$db->exec('CREATE TABLE IF NOT EXISTS collab_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket_uid TEXT NOT NULL,
filename TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (bucket_uid, filename)
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)');
// Phase 4: advisory edit locks on collab cards. A client that opens the // Phase 4: advisory edit locks on collab cards. A client that opens the
// editor for a collab card heartbeats into this table every 30s with a // editor for a collab card heartbeats into this table every 30s with a
// 60s TTL. Other clients poll the list and show a lock indicator on the // 60s TTL. Other clients poll the list and show a lock indicator on the
@@ -278,8 +363,18 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
// Cleanup: 24h ephemeral storage, expired sessions, old rate limits // Cleanup: 24h ephemeral storage, expired sessions, old rate limits
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"); $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
// Chat: 7 days, longer than card sync but still ephemeral // Chat retention:
$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); // - persistent = 1 (default, 1:1 non-disposable + all group messages)
// → never auto-deleted. Relay is the source of truth so late joiners
// and second devices can backfill full history.
// - persistent = 0 (disposable 1:1 threads) → drop after a 7-day grace
// window so the sender's /api/chat/sent receipt poll still sees the
// final state.
$db->exec("DELETE FROM chat_messages WHERE persistent = 0 AND created_at < datetime('now', '-7 days')");
// Sync events are transient toasts — drop fetched ones after an hour,
// everything after 24h regardless (sender was offline too long to care).
$db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')");
$db->exec("DELETE FROM relay_sync_events WHERE created_at < datetime('now', '-24 hours')");
// Collab-card tombstones: 7 days so late pollers still see the delete. // Collab-card tombstones: 7 days so late pollers still see the delete.
$db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')"); $db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')");
// Collab-card locks: drop everything past its TTL on every request. // Collab-card locks: drop everything past its TTL on every request.
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more