Compare commits
78
Commits
3cca14a3c6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a216906da | ||
|
|
130a1c660b | ||
|
|
4baeee159e | ||
|
|
8cbec8d601 | ||
|
|
7bce6a3a36 | ||
|
|
2d58cd993e | ||
|
|
87ebcd156b | ||
|
|
4aca21812e | ||
|
|
9f90ea516a | ||
|
|
0553960a57 | ||
|
|
1e51bdec7c | ||
|
|
f986a41ccc | ||
|
|
48b5907f4c | ||
|
|
89d84d3538 | ||
|
|
29e818ac64 | ||
|
|
53ce58a5aa | ||
|
|
72a7c7c7cc | ||
|
|
1dfffa5d6d | ||
|
|
d83de30d6d | ||
|
|
d50f9fe789 | ||
|
|
8e738ed9d4 | ||
|
|
c934673c4a | ||
|
|
6fa42eb267 | ||
|
|
5c9e9254d1 | ||
|
|
59be08a8f8 | ||
|
|
be7eb1523e | ||
|
|
4c3954d989 | ||
|
|
82b2ebc15f | ||
|
|
883640e20c | ||
|
|
be28080439 | ||
|
|
c07340a3ec | ||
|
|
d052fb5f9d | ||
|
|
b9d610b612 | ||
|
|
d925070a36 | ||
|
|
e086db0c2c | ||
|
|
670f125627 | ||
|
|
c2485b1307 | ||
|
|
b08bdbaeb2 | ||
|
|
0e3088db9a | ||
|
|
29f92dbf9e | ||
|
|
d5ebbab6a5 | ||
|
|
c9749352ec | ||
|
|
412dc527a3 | ||
|
|
27cc3f98ac | ||
|
|
64be9257f2 | ||
|
|
0022f64c50 | ||
|
|
d2f7b98e6c | ||
|
|
8020ddc787 | ||
|
|
87ce038b19 | ||
|
|
ccba2932c2 | ||
|
|
f2a33a9164 | ||
|
|
41272bd9ce | ||
|
|
d31e186bd9 | ||
|
|
0ac0e34012 | ||
|
|
dce8fa4211 | ||
|
|
8cd6015cad | ||
|
|
6aa555cc6a | ||
|
|
0fd97e5d71 | ||
|
|
9267c7c6a1 | ||
|
|
52da9abd44 | ||
|
|
044caae447 | ||
|
|
618a787b36 | ||
|
|
81d9ce0e87 | ||
|
|
bfd5420981 | ||
|
|
fa3a6c2501 | ||
|
|
1a889ea435 | ||
|
|
1d6499e5dd | ||
|
|
b7f450e41f | ||
|
|
6cd00f9660 | ||
|
|
4a668b7fc4 | ||
|
|
1966f8f4c0 | ||
|
|
26f95ba079 | ||
|
|
39e94b8015 | ||
|
|
69ad2c7e71 | ||
|
|
ae974314f9 | ||
|
|
3b7c7cf5ac | ||
|
|
ca47052768 | ||
|
|
002562b1d3 |
No files matched your search
@@ -11,3 +11,8 @@
|
|||||||
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/
|
||||||
|
|
||||||
|
relay/data
|
||||||
@@ -23,6 +23,22 @@ Open `http://localhost:8080` and set your password on first visit.
|
|||||||
|
|
||||||
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
||||||
|
|
||||||
|
### Via docker compose
|
||||||
|
|
||||||
|
If you prefer compose, the bundled `docker-compose.yml` starts the app alone by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # just the app on :8088
|
||||||
|
```
|
||||||
|
|
||||||
|
To also bring up a local relay (for multi-tenant messaging / collab / chat), opt in via the `relay` profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile relay up -d # app on :8088 + relay on :8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Most installs talk to a remote relay, so the app-only default keeps the local setup minimal.
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|
||||||
### 1. Clone
|
### 1. Clone
|
||||||
@@ -418,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
|
||||||
|
|||||||
+13
-1
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.4.2');
|
define('APP_VERSION', '2.0.16');
|
||||||
|
|
||||||
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
|
define('APP_DEBUG', getenv('APP_DEBUG') === '1' || getenv('APP_DEBUG') === 'true');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// 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
|
||||||
@@ -14,3 +18,11 @@ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|||||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
||||||
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
||||||
|
|
||||||
|
// Oumta dashboard upstream. The browser only talks to /api/dashboard/*
|
||||||
|
// same-origin; the PHP proxy below forwards to this URL. Override via env
|
||||||
|
// for remote deployments. Inside Docker on Linux, localhost points at the
|
||||||
|
// container itself — use http://host.docker.internal:8085 (needs
|
||||||
|
// extra_hosts) or a routable URL to reach a host-side dashboard.
|
||||||
|
define('OUMTA_DASHBOARD_URL', getenv('OUMTA_DASHBOARD_URL') ?: 'http://localhost:8085');
|
||||||
|
define('OUMTA_DASHBOARD_TOKEN', getenv('OUMTA_DASHBOARD_TOKEN') ?: '');
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Tear down the full dev stack and wipe the support-relay data (DB +
|
||||||
|
# master key). Hyprcrd and the main relay keep their data — this is
|
||||||
|
# meant for iterating on support-relay setup / API-key rotation.
|
||||||
|
#
|
||||||
|
# Usage: ./dev-down.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# If the stand-alone support-relay/docker-compose.yml was used earlier
|
||||||
|
# (before we folded it into the main compose), stop that too.
|
||||||
|
if [ -f support-relay/docker-compose.yml ]; then
|
||||||
|
( cd support-relay && docker compose down --remove-orphans ) 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop main dev stack across all profiles. `down` without -v preserves
|
||||||
|
# the named volumes (hyprcrd + relay data).
|
||||||
|
docker compose --profile relay --profile support down --remove-orphans
|
||||||
|
|
||||||
|
# Wipe support-relay storage — both the compose-managed named volume
|
||||||
|
# and the stand-alone bind-mount location. Match volumes by suffix so
|
||||||
|
# whatever compose-project-name was in effect (hyprcrd / something else)
|
||||||
|
# we find it.
|
||||||
|
for vol in $(docker volume ls -q | grep -E '(^|_)support-relay-data$' || true); do
|
||||||
|
docker volume rm -f "$vol" >/dev/null
|
||||||
|
echo " wiped volume: $vol"
|
||||||
|
done
|
||||||
|
if [ -d support-relay/data ]; then
|
||||||
|
rm -f support-relay/data/*.db support-relay/data/*.db-* support-relay/data/master.key 2>/dev/null || true
|
||||||
|
echo " wiped bind-mount: support-relay/data/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " all containers stopped."
|
||||||
|
echo " support-relay data gone — next boot runs the setup wizard again"
|
||||||
|
echo " and mints a fresh API key."
|
||||||
|
echo " hyprcrd + relay data preserved."
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Start hyprcrd + relay for local development
|
# Start hyprcrd + relay + support-relay for local development. Both
|
||||||
|
# auxiliary services sit behind compose profiles — this script opts
|
||||||
|
# into all of them so a dev can exercise the full sync + support flows
|
||||||
|
# without juggling profile flags.
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
docker compose up -d --build
|
#docker compose --profile relay --profile support up -d --build
|
||||||
|
docker compose --profile support up -d --build
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " hyprcrd: http://localhost:8088"
|
echo " hyprcrd: http://localhost:8088"
|
||||||
echo " relay: http://localhost:8081"
|
#echo " relay: http://localhost:8081"
|
||||||
|
echo " support-relay: http://localhost:8090"
|
||||||
echo ""
|
echo ""
|
||||||
echo " relay from hyprcrd container: http://relay:80"
|
#echo " relay from hyprcrd container: http://relay:80"
|
||||||
|
echo " support-relay from hyprcrd container: http://support-relay:80"
|
||||||
|
echo ""
|
||||||
|
echo " (for app-only, drop the profiles: docker compose up -d)"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -3,14 +3,27 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8088:80"
|
- "8088:80"
|
||||||
|
environment:
|
||||||
|
# `host.docker.internal` resolves to the Docker host via the
|
||||||
|
# extra_hosts mapping below, so the PHP proxy can reach a dashboard
|
||||||
|
# running directly on the host. Swap to a routable URL when the
|
||||||
|
# dashboard moves to a remote server.
|
||||||
|
OUMTA_DASHBOARD_URL: http://host.docker.internal:8085
|
||||||
|
# OUMTA_DASHBOARD_TOKEN: "" # Keycloak bearer, when auth is on
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/var/www/html/data
|
- ./data:/var/www/html/data
|
||||||
- ./src:/var/www/html/src
|
- ./src:/var/www/html/src
|
||||||
- ./public:/var/www/html/public
|
- ./public:/var/www/html/public
|
||||||
- ./config.php:/var/www/html/config.php
|
- ./config.php:/var/www/html/config.php
|
||||||
|
|
||||||
|
# Relay is opt-in via the `relay` profile. Plain `docker compose up`
|
||||||
|
# starts just the app; use `docker compose --profile relay up` when
|
||||||
|
# you want to develop against a local relay instead of a remote one.
|
||||||
relay:
|
relay:
|
||||||
build: ./relay
|
build: ./relay
|
||||||
|
profiles: ["relay"]
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -21,5 +34,19 @@ services:
|
|||||||
- ./relay/ui:/var/www/html/ui
|
- ./relay/ui:/var/www/html/ui
|
||||||
- relay-data:/var/www/html/data
|
- relay-data:/var/www/html/data
|
||||||
|
|
||||||
|
# Support-relay also opt-in via its own profile. Shares the compose
|
||||||
|
# network with the app so hyprcrd's support plugin can reach it at
|
||||||
|
# `http://support-relay:80` (container-to-container, no host port
|
||||||
|
# needed). Port 8090 is still published so you can open the public
|
||||||
|
# form in a browser.
|
||||||
|
support-relay:
|
||||||
|
build: ./support-relay
|
||||||
|
profiles: ["support"]
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
volumes:
|
||||||
|
- support-relay-data:/var/www/html/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
relay-data:
|
relay-data:
|
||||||
|
support-relay-data:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.3.5",
|
"version": "2.0.14",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+217
-30
@@ -29,7 +29,6 @@ body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
|||||||
}
|
}
|
||||||
.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 ── */
|
||||||
@@ -110,23 +109,27 @@ select[class*="border-t-accent"] {
|
|||||||
.prose img { border-radius: var(--ui-radius-image); }
|
.prose img { border-radius: var(--ui-radius-image); }
|
||||||
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
||||||
|
|
||||||
/* Modal containers (the inner box of each overlay) */
|
/* Modal containers (the inner box of each overlay). Only for floating /
|
||||||
#modal > div,
|
* framed modals — the fullscreen overlays (modal, detail, search, tasks,
|
||||||
#detail > div,
|
* calendar, gallery, and the support / plugin overlays) fill the whole
|
||||||
|
* viewport now and don't want the rounded-corner + overflow:hidden
|
||||||
|
* combo, which would clip the top color bar and soften the edge-to-edge
|
||||||
|
* look. */
|
||||||
#settings-overlay > div,
|
#settings-overlay > div,
|
||||||
#help > div,
|
#dialog > div {
|
||||||
#search > div,
|
|
||||||
#tasks > div,
|
|
||||||
#calendar > div,
|
|
||||||
#gallery > div,
|
|
||||||
#dialog > div,
|
|
||||||
#detail > div,
|
|
||||||
#modal > 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);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The help dialog scrolls its own body — keep the border-radius and
|
||||||
|
shadow but drop overflow:hidden so the Tailwind overflow-y-auto on
|
||||||
|
the same element actually takes effect. */
|
||||||
|
#help > div {
|
||||||
|
border-radius: var(--ui-radius-modal);
|
||||||
|
box-shadow: var(--ui-shadow-modal);
|
||||||
|
}
|
||||||
|
|
||||||
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
/* Overlay backdrop blur (applies to anything that's already an .overlay) */
|
||||||
.overlay { backdrop-filter: blur(var(--ui-blur-overlay)); -webkit-backdrop-filter: blur(var(--ui-blur-overlay)); }
|
.overlay { backdrop-filter: blur(var(--ui-blur-overlay)); -webkit-backdrop-filter: blur(var(--ui-blur-overlay)); }
|
||||||
|
|
||||||
@@ -172,7 +175,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;
|
||||||
@@ -227,7 +229,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; }
|
||||||
@@ -299,16 +301,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; } }
|
||||||
@@ -400,14 +392,28 @@ body::after {
|
|||||||
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
||||||
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
||||||
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
||||||
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
||||||
|
/* Rounded-cards variant: the corner triangle gets clipped by the
|
||||||
|
* rounded border, so toggle to a circle pill that sits comfortably
|
||||||
|
* inside any radius. JS adds `rounded-cards` on body whenever
|
||||||
|
* --ui-radius-card is > 0. */
|
||||||
|
body.rounded-cards .card-done-badge {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
bottom: 6px; right: 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
body.rounded-cards .card-done-badge i { position: static; bottom: auto; right: auto; font-size: 11px; color: #000; font-weight: bold; }
|
||||||
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
||||||
@@ -518,6 +524,36 @@ body::after {
|
|||||||
animation: card-remove 0.25s ease-in forwards;
|
animation: card-remove 0.25s ease-in forwards;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* Move-in-flight: card ghosted with a spinner overlay while the
|
||||||
|
* PATCH + reloadAll round-trip completes. The pseudo-elements paint
|
||||||
|
* a dim scrim and a centred spinning ring so the origin bucket
|
||||||
|
* clearly shows the card is "leaving". Removed by the next
|
||||||
|
* renderBoard() since the DOM is rebuilt from scratch. */
|
||||||
|
.board-card.card-moving {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
margin: -11px 0 0 -11px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: var(--t-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.box-select {
|
.box-select {
|
||||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||||
pointer-events: none; z-index: 10;
|
pointer-events: none; z-index: 10;
|
||||||
@@ -792,6 +828,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
|
||||||
@@ -944,7 +1024,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 */
|
||||||
@@ -1078,11 +1158,13 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
margin-left: -1.5em !important;
|
margin-left: -1.5em !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center;
|
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; }
|
||||||
@@ -1206,3 +1288,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,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-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
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-widLine truncated
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+515
-108
@@ -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();
|
||||||
|
|
||||||
@@ -45,6 +55,22 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
error_reporting(0);
|
error_reporting(0);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// IP-keyed rate limiter for auth endpoints — session cookies are trivial
|
||||||
|
// to rotate, so the in-session counter alone is bypassable.
|
||||||
|
$hyprcrdClientIp = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-10 minutes')");
|
||||||
|
$hyprcrdIpRateLimited = function (string $endpoint, int $max, int $windowMinutes = 5) use ($db, $hyprcrdClientIp): bool {
|
||||||
|
if ($hyprcrdClientIp === '') return false;
|
||||||
|
$q = $db->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')");
|
||||||
|
$q->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||||
|
return (int)$q->fetchColumn() >= $max;
|
||||||
|
};
|
||||||
|
$hyprcrdRecordAttempt = function (string $endpoint) use ($db, $hyprcrdClientIp): void {
|
||||||
|
if ($hyprcrdClientIp === '') return;
|
||||||
|
$db->prepare('INSERT INTO login_attempts (ip, endpoint) VALUES (:ip, :ep)')
|
||||||
|
->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/auth/status
|
// GET /api/auth/status
|
||||||
if ($route === '/api/auth/status' && $method === 'GET') {
|
if ($route === '/api/auth/status' && $method === 'GET') {
|
||||||
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
|
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
|
||||||
@@ -53,11 +79,20 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
|
|
||||||
// POST /api/auth/setup — set initial password
|
// POST /api/auth/setup — set initial password
|
||||||
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
|
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
|
||||||
|
// CSRF: same-origin + same-session guarantee. A drive-by POST from
|
||||||
|
// another site won't have the csrf cookie-bound token, so it can't
|
||||||
|
// silently claim a freshly-provisioned instance.
|
||||||
|
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'invalid csrf token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$pw = $input['password'] ?? '';
|
$pw = $input['password'] ?? '';
|
||||||
if (strlen($pw) < 4) {
|
if (strlen($pw) < 10) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'password too short (min 4)']);
|
echo json_encode(['error' => 'password too short (min 10)']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
||||||
@@ -69,13 +104,11 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/login (rate limited)
|
// POST /api/auth/login (IP-keyed rate limit)
|
||||||
if ($route === '/api/auth/login' && $method === 'POST') {
|
if ($route === '/api/auth/login' && $method === 'POST') {
|
||||||
$attempts = $_SESSION['login_attempts'] ?? 0;
|
if ($hyprcrdIpRateLimited('login', 10, 5)) {
|
||||||
$lastAttempt = $_SESSION['login_last'] ?? 0;
|
|
||||||
if ($attempts >= 5 && time() - $lastAttempt < 60) {
|
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
echo json_encode(['error' => 'too many attempts, wait 60s']);
|
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -84,11 +117,9 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['hyprcrd_auth'] = true;
|
$_SESSION['hyprcrd_auth'] = true;
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
$_SESSION['login_attempts'] = 0;
|
|
||||||
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
|
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['login_attempts'] = $attempts + 1;
|
$hyprcrdRecordAttempt('login');
|
||||||
$_SESSION['login_last'] = time();
|
|
||||||
usleep(300000); // 300ms delay against timing attacks
|
usleep(300000); // 300ms delay against timing attacks
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'wrong password']);
|
echo json_encode(['error' => 'wrong password']);
|
||||||
@@ -149,18 +180,24 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
|
|
||||||
// POST /api/auth/password — change password (requires current password)
|
// POST /api/auth/password — change password (requires current password)
|
||||||
if ($route === '/api/auth/password' && $method === 'POST') {
|
if ($route === '/api/auth/password' && $method === 'POST') {
|
||||||
|
if ($hyprcrdIpRateLimited('password', 10, 5)) {
|
||||||
|
http_response_code(429);
|
||||||
|
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$current = $input['current'] ?? '';
|
$current = $input['current'] ?? '';
|
||||||
$newPass = $input['new'] ?? '';
|
$newPass = $input['new'] ?? '';
|
||||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
if (!password_verify($current, $hash)) {
|
if (!password_verify($current, $hash)) {
|
||||||
|
$hyprcrdRecordAttempt('password');
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'wrong password']);
|
echo json_encode(['error' => 'wrong password']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if (strlen($newPass) < 4) {
|
if (strlen($newPass) < 10) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'min 4 characters']);
|
echo json_encode(['error' => 'min 10 characters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
|
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
|
||||||
@@ -236,7 +273,113 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
|
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
|
||||||
function extractTags($body) {
|
function extractTags($body) {
|
||||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
|
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
|
||||||
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
// English ↔ German tag aliases: the calendar + journal logic is
|
||||||
|
// hardcoded against 'geburtstag' / 'termin', so accept the English
|
||||||
|
// synonyms and normalise to the canonical German form at write
|
||||||
|
// time. Keeps every downstream check (card.tags includes 'geburtstag')
|
||||||
|
// working without touching dozens of JS call sites.
|
||||||
|
$aliases = ['birthday' => 'geburtstag', 'appointment' => 'termin'];
|
||||||
|
$tags = array_map(fn($t) => $aliases[$t] ?? $t, array_map('strtolower', $matches[1] ?? []));
|
||||||
|
return implode(',', array_unique($tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-later push helpers — called after card insert/update when
|
||||||
|
// cards.read_push is 1, or when the user toggles it off and we need
|
||||||
|
// to wipe the remote copy. Failures are logged and swallowed so
|
||||||
|
// relay trouble never blocks a local save.
|
||||||
|
function pushCardToRelayRead(array $card, PDO $db): void {
|
||||||
|
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
|
||||||
|
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
|
||||||
|
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
|
||||||
|
if (!$relayUrl || !$relayKey || !$relayUser) return;
|
||||||
|
|
||||||
|
$relayUid = $card['relay_uid'] ?? null;
|
||||||
|
if (!$relayUid) {
|
||||||
|
$relayUid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $card['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (string)($card['body'] ?? '');
|
||||||
|
// Hero = first uploads/<hex>.<ext> reference. All inline images
|
||||||
|
// get pushed as attachments so the reader can resolve them.
|
||||||
|
preg_match_all('#uploads/([a-f0-9]+\.[a-z0-9]+)#i', $body, $matches);
|
||||||
|
$imageFilenames = array_unique(array_map('strtolower', $matches[1] ?? []));
|
||||||
|
$heroFilename = $imageFilenames[0] ?? '';
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'relay_uid' => $relayUid,
|
||||||
|
'title' => hyprcrdRelayEncrypt((string)($card['title'] ?? ''), $relayKey),
|
||||||
|
'body' => hyprcrdRelayEncrypt($body, $relayKey),
|
||||||
|
'hero_filename' => $heroFilename,
|
||||||
|
]);
|
||||||
|
// Aggressive timeouts: read-later is opt-in + best-effort. If the
|
||||||
|
// relay is slow or unreachable we must NOT block the user's save —
|
||||||
|
// this function still runs inline from the PATCH handler (we'll
|
||||||
|
// also flush the response before invoking it), so the shorter
|
||||||
|
// timeout caps the worst-case backlog even if flushing fails.
|
||||||
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status < 200 || $status >= 300) {
|
||||||
|
@error_log('[hyprcrd] read push failed: HTTP ' . $status . ' ' . substr((string)$resp, 0, 200));
|
||||||
|
return; // don't bother uploading images if the item didn't land
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload referenced images as read-attachments (encrypted blob,
|
||||||
|
// same envelope format as collab-attachments).
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
foreach ($imageFilenames as $fn) {
|
||||||
|
$filePath = $uploadDir . '/' . $fn;
|
||||||
|
if (!is_file($filePath)) continue;
|
||||||
|
$bytes = file_get_contents($filePath);
|
||||||
|
if ($bytes === false) continue;
|
||||||
|
$attPayload = json_encode([
|
||||||
|
'relay_uid' => $relayUid,
|
||||||
|
'filename' => $fn,
|
||||||
|
'data' => hyprcrdRelayEncrypt(base64_encode($bytes), $relayKey),
|
||||||
|
]);
|
||||||
|
$ach = curl_init(rtrim($relayUrl, '/') . '/api/read-attachments');
|
||||||
|
curl_setopt_array($ach, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $attPayload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
|
curl_exec($ach);
|
||||||
|
curl_close($ach);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCardFromRelayRead(string $relayUid, PDO $db): void {
|
||||||
|
if ($relayUid === '') return;
|
||||||
|
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
|
||||||
|
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
|
||||||
|
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
|
||||||
|
if (!$relayUrl || !$relayKey || !$relayUser) return;
|
||||||
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read/' . urlencode($relayUid));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin route handlers (registered by plugin backend.php files).
|
// Plugin route handlers (registered by plugin backend.php files).
|
||||||
@@ -250,7 +393,8 @@ 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, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
|
$readPush = !empty($input['read_push']) ? 1 : 0;
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad, read_push) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad, :read_push)')->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),
|
||||||
@@ -258,8 +402,24 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
'tags' => extractTags($body),
|
'tags' => extractTags($body),
|
||||||
'due_date' => $input['due_date'] ?? null,
|
'due_date' => $input['due_date'] ?? null,
|
||||||
'scratchpad' => $input['scratchpad'] ?? null,
|
'scratchpad' => $input['scratchpad'] ?? null,
|
||||||
|
'read_push' => $readPush,
|
||||||
]);
|
]);
|
||||||
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());
|
$saved = $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();
|
||||||
|
// When the card opts into read-later, generate the relay_uid up
|
||||||
|
// front so the response the client receives already carries it.
|
||||||
|
// Actually pushing to the relay happens AFTER the response is
|
||||||
|
// flushed — the relay hop is best-effort and shouldn't stretch
|
||||||
|
// the user-facing save latency.
|
||||||
|
if ($readPush && $saved && empty($saved['relay_uid'])) {
|
||||||
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $saved['id']]);
|
||||||
|
$saved['relay_uid'] = $uid;
|
||||||
|
}
|
||||||
|
echo json_encode($saved);
|
||||||
|
if ($readPush && $saved) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($saved, $db);
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,20 +439,28 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/cards/meta' && $method === 'GET') {
|
if ($route === '/api/cards/meta' && $method === 'GET') {
|
||||||
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
|
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, c.read_push, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
// CalDAV tombstone for synced cards
|
// CalDAV tombstone for synced cards, plus a read-list wipe so the
|
||||||
$card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id');
|
// mobile reader stops showing cards that no longer exist locally.
|
||||||
|
$card = $db->prepare('SELECT caldav_uid, relay_uid, read_push FROM cards WHERE id = :id');
|
||||||
$card->execute(['id' => $m[1]]);
|
$card->execute(['id' => $m[1]]);
|
||||||
$uid = $card->fetchColumn();
|
$cardRow = $card->fetch();
|
||||||
if ($uid) {
|
if ($cardRow && !empty($cardRow['caldav_uid'])) {
|
||||||
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]);
|
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]);
|
||||||
}
|
}
|
||||||
|
$wipeFromRelay = ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid']))
|
||||||
|
? (string)$cardRow['relay_uid'] : null;
|
||||||
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]);
|
||||||
|
if ($wipeFromRelay) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead($wipeFromRelay, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
||||||
@@ -323,10 +491,54 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] 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('read_push', $input)) {
|
||||||
|
$fields[] = 'read_push = :read_push';
|
||||||
|
$params['read_push'] = !empty($input['read_push']) ? 1 : 0;
|
||||||
|
}
|
||||||
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']); }
|
||||||
|
// Snapshot the previous read_push / relay_uid before the write so we
|
||||||
|
// can detect a toggle-off and remove the remote copy afterwards.
|
||||||
|
// The history-tracking block above only loads $old when a tracked
|
||||||
|
// field changed; we need it unconditionally when read_push is in
|
||||||
|
// play, so backfill here if missing.
|
||||||
|
$prevRead = null; $prevRelayUid = null;
|
||||||
|
if (array_key_exists('read_push', $input) && !isset($old)) {
|
||||||
|
$snap = $db->prepare('SELECT read_push, relay_uid FROM cards WHERE id = :id');
|
||||||
|
$snap->execute(['id' => $m[1]]);
|
||||||
|
$old = $snap->fetch() ?: null;
|
||||||
|
}
|
||||||
|
if (isset($old) && $old) { $prevRead = (int)($old['read_push'] ?? 0); $prevRelayUid = $old['relay_uid'] ?? null; }
|
||||||
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
||||||
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
||||||
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
|
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
|
||||||
|
// Decide the relay-sync action BEFORE echoing the response, then
|
||||||
|
// flush + run the actual cURL hop afterwards. The user doesn't
|
||||||
|
// wait for the relay round-trip; a dead/slow relay no longer
|
||||||
|
// stretches the card save into "eine Ewigkeit".
|
||||||
|
$pushAction = null; // 'push' | 'delete' | null
|
||||||
|
if ($savedCard) {
|
||||||
|
$nowRead = (int)($savedCard['read_push'] ?? 0);
|
||||||
|
if ($nowRead === 1) {
|
||||||
|
// Mint relay_uid up front so the response carries it.
|
||||||
|
if (empty($savedCard['relay_uid'])) {
|
||||||
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $savedCard['id']]);
|
||||||
|
$savedCard['relay_uid'] = $uid;
|
||||||
|
}
|
||||||
|
$pushAction = 'push';
|
||||||
|
} elseif ($prevRead === 1 && $prevRelayUid) {
|
||||||
|
$pushAction = 'delete';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode($savedCard);
|
||||||
|
if ($pushAction === 'push') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($savedCard, $db);
|
||||||
|
} elseif ($pushAction === 'delete') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead((string)$prevRelayUid, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
@@ -742,21 +954,37 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract uploads
|
// Extract uploads. Guard against zip bombs and malicious payloads:
|
||||||
|
// per-file 25MB cap, 500MB total, whitelist allowed extensions (same
|
||||||
|
// set as the live /api/upload endpoint), never overwrite existing
|
||||||
|
// files, never leak paths via traversal.
|
||||||
$uploadDir = UPLOAD_DIR;
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||||
|
$allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'm4a', 'pdf'];
|
||||||
|
$perFileMax = 25 * 1024 * 1024; // 25 MB
|
||||||
|
$totalMax = 500 * 1024 * 1024; // 500 MB
|
||||||
|
$totalWritten = 0;
|
||||||
|
$skipped = [];
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$name = $zip->getNameIndex($i);
|
$name = $zip->getNameIndex($i);
|
||||||
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
|
if (!str_starts_with($name, 'uploads/') || $name === 'uploads/') continue;
|
||||||
$content = $zip->getFromIndex($i);
|
$base = basename($name);
|
||||||
if ($content !== false) {
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
file_put_contents($uploadDir . '/' . basename($name), $content);
|
if (!in_array($ext, $allowedExts, true)) { $skipped[] = $base; continue; }
|
||||||
}
|
$stat = $zip->statIndex($i);
|
||||||
}
|
if ($stat && isset($stat['size']) && $stat['size'] > $perFileMax) { $skipped[] = $base; continue; }
|
||||||
|
if ($totalWritten + (int)($stat['size'] ?? 0) > $totalMax) { $skipped[] = $base; continue; }
|
||||||
|
$dest = $uploadDir . '/' . $base;
|
||||||
|
if (file_exists($dest)) { $skipped[] = $base; continue; }
|
||||||
|
$content = $zip->getFromIndex($i);
|
||||||
|
if ($content === false) continue;
|
||||||
|
if (strlen($content) > $perFileMax) { $skipped[] = $base; continue; }
|
||||||
|
file_put_contents($dest, $content);
|
||||||
|
$totalWritten += strlen($content);
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
|
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards']), 'skipped_files' => count($skipped)]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,24 +1005,82 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
||||||
echo json_encode($caldav->sync()); exit;
|
echo json_encode($caldav->sync()); exit;
|
||||||
}
|
}
|
||||||
|
// ── Oumta dashboard proxy ──
|
||||||
|
// Browser talks to /api/dashboard/* same-origin; we forward everything
|
||||||
|
// after that prefix to OUMTA_DASHBOARD_URL. Keeps CORS out of the
|
||||||
|
// browser, lets the dashboard live on a private network, and keeps any
|
||||||
|
// bearer token server-side. No SSE / streaming here: the response is
|
||||||
|
// buffered whole, so the plugin uses its polling fallback.
|
||||||
|
if (str_starts_with($route, '/api/dashboard/')) {
|
||||||
|
$upstreamPath = substr($route, strlen('/api/dashboard'));
|
||||||
|
$upstream = rtrim(OUMTA_DASHBOARD_URL, '/') . $upstreamPath;
|
||||||
|
$query = $_SERVER['QUERY_STRING'] ?? '';
|
||||||
|
if ($query !== '') $upstream .= '?' . $query;
|
||||||
|
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
if (OUMTA_DASHBOARD_TOKEN !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . OUMTA_DASHBOARD_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($upstream);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
if ($method !== 'GET' && $method !== 'HEAD') {
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
if ($body !== '' && $body !== false) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
$headers[] = 'Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'application/json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$ct = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status === 0) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'dashboard upstream unreachable', 'detail' => APP_DEBUG ? $err : '']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code($status);
|
||||||
|
if ($ct) header('Content-Type: ' . $ct);
|
||||||
|
echo $resp;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Relay ──
|
// ── Relay ──
|
||||||
|
|
||||||
if ($route === '/api/relay/settings' && $method === 'GET') {
|
if ($route === '/api/relay/settings' && $method === 'GET') {
|
||||||
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
|
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
|
||||||
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
|
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
|
||||||
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
|
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
|
||||||
// Single-user local app — echo the stored secret back so the user can
|
// Never echo the plaintext secret to the client — if it leaks via XSS
|
||||||
// see and edit it directly without guessing if it's still saved.
|
// or a compromised session, the attacker can decrypt all relay traffic.
|
||||||
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
|
// The UI just needs to know whether a secret is set.
|
||||||
|
echo json_encode(['url' => $url, 'username' => $username, 'has_key' => !empty($key)]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/relay/settings' && $method === 'POST') {
|
if ($route === '/api/relay/settings' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
|
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
|
||||||
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
|
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
|
||||||
// Always overwrite key when present (even empty) — single-user local
|
// The UI no longer receives the stored secret, so empty strings are
|
||||||
// app, the input is now pre-populated so an empty submit is intentional
|
// just "field untouched" and we only write when the user types a new
|
||||||
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
|
// value. An explicit null clears the secret for users who need it.
|
||||||
|
if (array_key_exists('key', $input)) {
|
||||||
|
$newKey = $input['key'];
|
||||||
|
if ($newKey === null) {
|
||||||
|
$db->prepare("DELETE FROM settings WHERE key = 'relay_key'")->execute();
|
||||||
|
} elseif (is_string($newKey) && $newKey !== '') {
|
||||||
|
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $newKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,6 +1107,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -841,13 +1128,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
|
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
|
||||||
|
|
||||||
$pulled = 0;
|
$pulled = 0;
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
$decryptField = function($text) use ($relayKey) {
|
||||||
$decryptField = function($text) use ($encKey) {
|
$decoded = hyprcrdRelayDecrypt($text, $relayKey);
|
||||||
if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text;
|
// Dual-format decrypt returns '' only when a magic-prefixed
|
||||||
$raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? ''));
|
// envelope failed to decrypt — surface the original string in
|
||||||
if (strlen($raw) < 17) return $text;
|
// that case so the UI still shows something useful.
|
||||||
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
if ($decoded === '' && (str_starts_with($text, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($text, HYPRCRD_RELAY_CBC_MAGIC))) {
|
||||||
return $plain !== false ? $plain : $text;
|
return $text;
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load IDs we pushed ourselves
|
// Load IDs we pushed ourselves
|
||||||
@@ -892,6 +1181,33 @@ 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,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($bch);
|
||||||
|
$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,6 +1216,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
$attResponse = curl_exec($ach);
|
$attResponse = curl_exec($ach);
|
||||||
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
|
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ach);
|
curl_close($ach);
|
||||||
@@ -913,29 +1230,62 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
|
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
|
||||||
$dest = $uploadDir . '/' . $fn;
|
$dest = $uploadDir . '/' . $fn;
|
||||||
if (file_exists($dest)) continue;
|
if (file_exists($dest)) continue;
|
||||||
// New format: HYPRCRD-RELAY-V1 envelope (decrypt-reencrypted by relay)
|
// Dual-format: GCM-V2 envelope (v1.10+) and legacy
|
||||||
// Old format: raw base64(iv || cipher)
|
// CBC-V1 envelope; plaintext inside is base64(file
|
||||||
|
// bytes). Very-old pre-envelope format was raw
|
||||||
|
// base64(iv || cipher) — unlikely at this point but
|
||||||
|
// cheap to keep as a last-resort fallback.
|
||||||
$payload = $att['data'] ?? '';
|
$payload = $att['data'] ?? '';
|
||||||
if (str_starts_with($payload, 'HYPRCRD-RELAY-V1')) {
|
$decrypted = false;
|
||||||
$b64 = trim(substr($payload, strlen('HYPRCRD-RELAY-V1') + 1));
|
if (str_starts_with($payload, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($payload, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
$raw = base64_decode($b64);
|
$plain = hyprcrdRelayDecrypt($payload, $relayKey);
|
||||||
|
if ($plain !== '') $decrypted = $plain;
|
||||||
} else {
|
} else {
|
||||||
|
$legacyKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||||
$raw = base64_decode($payload);
|
$raw = base64_decode($payload);
|
||||||
}
|
if ($raw !== false && strlen($raw) >= 17) {
|
||||||
if (strlen($raw) >= 17) {
|
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $legacyKey, 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) {
|
|
||||||
$fileBytes = base64_decode($decrypted);
|
|
||||||
if ($fileBytes !== false) $decrypted = $fileBytes;
|
|
||||||
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,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($sch);
|
||||||
|
$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)
|
||||||
@@ -952,6 +1302,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -991,12 +1342,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
$encryptField = fn($text) => hyprcrdRelayEncrypt((string)$text, $relayKey);
|
||||||
$encryptField = function($text) use ($encKey) {
|
|
||||||
$iv = random_bytes(16);
|
|
||||||
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
};
|
|
||||||
|
|
||||||
$payloadData = [
|
$payloadData = [
|
||||||
'title' => $encryptField($card['title'] ?? ''),
|
'title' => $encryptField($card['title'] ?? ''),
|
||||||
@@ -1009,21 +1355,27 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$payload = json_encode($payloadData);
|
$payload = json_encode($payloadData);
|
||||||
|
|
||||||
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
|
||||||
$debugStream = fopen('php://temp', 'w+');
|
$debugStream = APP_DEBUG ? fopen('php://temp', 'w+') : null;
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_VERBOSE => true,
|
|
||||||
CURLOPT_STDERR => $debugStream,
|
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
if ($debugStream) {
|
||||||
|
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||||
|
curl_setopt($ch, CURLOPT_STDERR, $debugStream);
|
||||||
|
}
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
rewind($debugStream);
|
$curlDebug = '';
|
||||||
$curlDebug = stream_get_contents($debugStream);
|
if ($debugStream) {
|
||||||
fclose($debugStream);
|
rewind($debugStream);
|
||||||
|
$curlDebug = stream_get_contents($debugStream);
|
||||||
|
fclose($debugStream);
|
||||||
|
}
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($status === 200 || $status === 201) {
|
if ($status === 200 || $status === 201) {
|
||||||
@@ -1058,6 +1410,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
curl_exec($ach);
|
curl_exec($ach);
|
||||||
curl_close($ach);
|
curl_close($ach);
|
||||||
}
|
}
|
||||||
@@ -1072,16 +1425,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
} elseif (is_string($response) && $response !== '') {
|
} elseif (is_string($response) && $response !== '') {
|
||||||
$relayMsg = ' — ' . substr($response, 0, 200);
|
$relayMsg = ' — ' . substr($response, 0, 200);
|
||||||
}
|
}
|
||||||
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
|
// In production, don't leak upstream URL / raw bodies / curl traces —
|
||||||
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
// APP_DEBUG=1 re-enables them for local troubleshooting.
|
||||||
echo json_encode([
|
$out = ['ok' => false, 'error' => "relay returned HTTP $status$relayMsg", 'sent_username' => $relayUser];
|
||||||
'ok' => false,
|
if (APP_DEBUG) {
|
||||||
'error' => "relay returned HTTP $status$relayMsg",
|
$out['relay_url'] = rtrim($relayUrl, '/') . '/api/messages';
|
||||||
'sent_username' => $relayUser,
|
$out['response_body'] = is_string($response) ? substr($response, 0, 500) : null;
|
||||||
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
|
$out['curl_debug'] = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
||||||
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
|
}
|
||||||
'curl_debug' => $safeDebug,
|
echo json_encode($out);
|
||||||
]);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,6 +1448,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>hyprcrd</title>
|
<title>hyprcrd</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="css/fonts.css">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
||||||
@@ -1114,6 +1467,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
paper: { label:'Bright Mind', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0.05)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
|
paper: { label:'Bright Mind', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0.05)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
|
||||||
sand: { label:'Sand', vars:{ '--t-bg':'#d6c5a3','--t-surface':'#e2d5b8','--t-raised':'#ede2c5','--t-border':'#a89572','--t-dim':'#8a7654','--t-muted':'#5d4f36','--t-text':'#3a3120','--t-bright':'#1a1408','--t-accent':'#b91c1c','--t-accent2':'#15803d','--t-danger':'#7c2d12','--t-scanline':'rgba(60,40,20,0.06)','--t-overlay':'rgba(60,50,30,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(214,196,160,0.22) 0%, rgba(214,196,160,0.7) 62%, rgba(214,196,160,0.86) 100%)' }},
|
sand: { label:'Sand', vars:{ '--t-bg':'#d6c5a3','--t-surface':'#e2d5b8','--t-raised':'#ede2c5','--t-border':'#a89572','--t-dim':'#8a7654','--t-muted':'#5d4f36','--t-text':'#3a3120','--t-bright':'#1a1408','--t-accent':'#b91c1c','--t-accent2':'#15803d','--t-danger':'#7c2d12','--t-scanline':'rgba(60,40,20,0.06)','--t-overlay':'rgba(60,50,30,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(214,196,160,0.22) 0%, rgba(214,196,160,0.7) 62%, rgba(214,196,160,0.86) 100%)' }},
|
||||||
stone: { label:'Stone', vars:{ '--t-bg':'#94a3b8','--t-surface':'#a8b6c8','--t-raised':'#bcc7d5','--t-border':'#64748b','--t-dim':'#475569','--t-muted':'#334155','--t-text':'#1e293b','--t-bright':'#0f172a','--t-accent':'#1d4ed8','--t-accent2':'#7e22ce','--t-danger':'#991b1b','--t-scanline':'rgba(15,23,42,0.06)','--t-overlay':'rgba(50,60,80,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.7) 62%, rgba(148,163,184,0.86) 100%)' }},
|
stone: { label:'Stone', vars:{ '--t-bg':'#94a3b8','--t-surface':'#a8b6c8','--t-raised':'#bcc7d5','--t-border':'#64748b','--t-dim':'#475569','--t-muted':'#334155','--t-text':'#1e293b','--t-bright':'#0f172a','--t-accent':'#1d4ed8','--t-accent2':'#7e22ce','--t-danger':'#991b1b','--t-scanline':'rgba(15,23,42,0.06)','--t-overlay':'rgba(50,60,80,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.7) 62%, rgba(148,163,184,0.86) 100%)' }},
|
||||||
|
apple: { label:'Apple Dark', vars:{ '--t-bg':'#000000','--t-surface':'#1c1c1e','--t-raised':'#2c2c2e','--t-border':'#3a3a3c','--t-dim':'#636366','--t-muted':'#8e8e93','--t-text':'#ebebf5','--t-bright':'#ffffff','--t-accent':'#0a84ff','--t-accent2':'#bf5af2','--t-danger':'#ff453a','--t-scanline':'rgba(255,255,255,0.02)','--t-overlay':'rgba(0,0,0,0.7)','--t-font-pixel':'-apple-system, "SF Pro Display", system-ui, sans-serif','--t-cover-gradient':'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.72) 62%, rgba(0,0,0,0.88) 100%)' }},
|
||||||
};
|
};
|
||||||
window.applyTheme = function(name) {
|
window.applyTheme = function(name) {
|
||||||
const t = themes[name] || themes.slate;
|
const t = themes[name] || themes.slate;
|
||||||
@@ -1158,7 +1512,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,
|
||||||
@@ -1172,13 +1525,19 @@ 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,
|
||||||
padCard: 10, gapBoard: 17,
|
padCard: 10, gapBoard: 17,
|
||||||
shadowCard: 'none', shadowModal: 'none', shadowUi: 'none',
|
shadowCard: 'none', shadowModal: 'none', shadowUi: 'none',
|
||||||
liftCard: 2, blurOverlay: 5, animSpeed: 'normal' },
|
liftCard: 2, blurOverlay: 5, animSpeed: 'normal' },
|
||||||
|
'Apple Dark': { theme: 'apple',
|
||||||
|
fontHeadline: 'sans', fontContent: 'sans', fontUi: 'sans',
|
||||||
|
radiusCard: 12, radiusButton: 8, radiusInput: 8, radiusModal: 14, radiusTag: 6, radiusImage: 10,
|
||||||
|
borderCard: 1, borderStyle: 'solid', barHeight: 2,
|
||||||
|
padCard: 14, gapBoard: 18,
|
||||||
|
shadowCard: 'soft', shadowModal: 'medium', shadowUi: 'soft',
|
||||||
|
liftCard: 2, blurOverlay: 12, animSpeed: 'normal' },
|
||||||
};
|
};
|
||||||
window.applyDesign = function(d) {
|
window.applyDesign = function(d) {
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
@@ -1210,11 +1569,15 @@ 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');
|
||||||
if (d.animSpeed === 'slow') document.body.classList.add('anim-slow');
|
if (d.animSpeed === 'slow') document.body.classList.add('anim-slow');
|
||||||
|
// Rounded-card badge variant: the default "done" corner triangle
|
||||||
|
// gets clipped by a rounded border, so when the user opts into
|
||||||
|
// rounded cards we swap it for a circle pill in the BR corner
|
||||||
|
// (see .rounded-cards .card-done-badge in style.css).
|
||||||
|
document.body.classList.toggle('rounded-cards', (Number(d.radiusCard) || 0) > 0);
|
||||||
};
|
};
|
||||||
window.loadDesign = function() {
|
window.loadDesign = function() {
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -1222,8 +1585,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;
|
||||||
@@ -1253,11 +1614,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" 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>
|
||||||
|
<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">
|
||||||
@@ -1585,6 +1951,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 and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on 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">
|
||||||
@@ -1649,7 +2030,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>
|
||||||
@@ -1696,7 +2076,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>
|
||||||
@@ -1769,6 +2148,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>
|
||||||
@@ -1841,6 +2229,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
||||||
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
||||||
|
<div class="toggle-row" data-toggle="setting-open-switches-bucket"><span class="toggle-label">Switch to card's bucket on open (search / link)</span><div class="toggle-switch" id="setting-open-switches-bucket"></div></div>
|
||||||
|
<div class="toggle-row" data-toggle="setting-tasks-show-shopping"><span class="toggle-label">Show shopping list items in task collector</span><div class="toggle-switch" id="setting-tasks-show-shopping"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1888,12 +2278,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>
|
||||||
@@ -1909,6 +2300,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">
|
||||||
@@ -1922,9 +2314,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>
|
||||||
@@ -1957,12 +2350,21 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex flex-col border-b border-t-border">
|
<div class="flex flex-col border-b border-t-border">
|
||||||
<div class="flex items-center gap-3 px-4 py-3">
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<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>
|
||||||
|
<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.">
|
<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>
|
||||||
@@ -2001,7 +2403,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Tasks -->
|
<!-- Tasks -->
|
||||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
|
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:54px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
||||||
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
|
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
|
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
|
||||||
@@ -2026,7 +2428,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Detail -->
|
<!-- Detail -->
|
||||||
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
|
||||||
<div class="bg-t-surface border border-t-border flex absolute" style="inset:40px;">
|
<div class="bg-t-surface flex absolute" style="inset:0;">
|
||||||
<!-- Group sidebar (hidden for single cards) -->
|
<!-- Group sidebar (hidden for single cards) -->
|
||||||
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
|
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
|
||||||
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
|
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
|
||||||
@@ -2052,6 +2454,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<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.">
|
<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>
|
||||||
|
<button id="detail-read-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="Read later\nPush this card to your relay account so you can read it in the distraction-free mobile reader.">
|
||||||
|
<i id="detail-read-icon" class="ph ph-book-open text-sm"></i><span id="detail-read-label" class="font-pixel text-[10px]">Read</span>
|
||||||
|
</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" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
|
<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>
|
||||||
@@ -2091,15 +2496,17 @@ 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 flex flex-col absolute" style="inset:0;">
|
||||||
<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>
|
||||||
@@ -2112,7 +2519,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Assets (was Gallery) -->
|
<!-- Assets (was Gallery) -->
|
||||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
||||||
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
||||||
@@ -2185,7 +2592,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
||||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
||||||
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
||||||
|
|||||||
+60
-22
@@ -18,7 +18,8 @@ import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcut
|
|||||||
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) ──
|
||||||
@@ -41,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;
|
||||||
@@ -76,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();
|
||||||
}
|
}
|
||||||
@@ -87,16 +102,20 @@ async function loadCards() {
|
|||||||
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
||||||
const meta = await api('api/cards/meta');
|
const meta = await api('api/cards/meta');
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
state.cardsLoaded = true;
|
||||||
state.cards = meta.map(c => ({ ...c, body: null }));
|
state.cards = meta.map(c => ({ ...c, body: null }));
|
||||||
setCachedCards(meta);
|
setCachedCards(meta);
|
||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
// Phase 2: Load full cards in batches → replace spinners with content
|
// Phase 2: Load full card bodies. One fetch up to the ID cap — with
|
||||||
const batchSize = 25;
|
// the /api/cards(?ids=) endpoint now indexed on cards.position +
|
||||||
for (let i = 0; i < meta.length; i += batchSize) {
|
// created_at a single big batch is faster than many small ones, and
|
||||||
const batch = meta.slice(i, i + batchSize);
|
// shorter perceived spinner time matters more than memory. Fall back
|
||||||
const ids = batch.map(c => c.id).join(',');
|
// to smaller batches only if the URL would blow past ~2000 chars.
|
||||||
|
const IDS_MAX_PER_FETCH = 500;
|
||||||
|
for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) {
|
||||||
|
const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(',');
|
||||||
const full = await api('api/cards?ids=' + ids);
|
const full = await api('api/cards?ids=' + ids);
|
||||||
if (!full) continue;
|
if (!full) continue;
|
||||||
for (const card of full) {
|
for (const card of full) {
|
||||||
@@ -148,7 +167,7 @@ importFile?.addEventListener('change', async e => {
|
|||||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
||||||
}
|
}
|
||||||
@@ -164,6 +183,32 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
|||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-usable login prompt loop. Shared between the boot-time auth check
|
||||||
|
// and the 401-driven re-auth path (session expiry while the UI is open).
|
||||||
|
// Concurrent callers collapse onto a single dialog via reauthPromise so a
|
||||||
|
// wave of background polls doesn't stack up N prompts.
|
||||||
|
let reauthPromise = null;
|
||||||
|
async function promptLogin(message = '') {
|
||||||
|
if (reauthPromise) return reauthPromise;
|
||||||
|
reauthPromise = (async () => {
|
||||||
|
while (true) {
|
||||||
|
const pw = await showDialog({ title: 'login', message, input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
||||||
|
if (!pw) continue;
|
||||||
|
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// wrong password — loop and re-prompt
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try { await reauthPromise; } finally { reauthPromise = null; }
|
||||||
|
}
|
||||||
|
// Expose for the api() helpers so a 401 from any fetch triggers the same
|
||||||
|
// dialog + CSRF refresh flow.
|
||||||
|
window._reauth = () => promptLogin('Session expired — sign in to continue.');
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const res = await fetch('api/auth/status');
|
const res = await fetch('api/auth/status');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -178,24 +223,16 @@ async function checkAuth() {
|
|||||||
|
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
let pw = null;
|
let pw = null;
|
||||||
while (!pw || pw.length < 4) {
|
while (!pw || pw.length < 10) {
|
||||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
|
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||||
if (pw && pw.length < 4) pw = null;
|
if (pw && pw.length < 10) pw = null;
|
||||||
}
|
}
|
||||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { location.reload(); return; }
|
if (d.error) { location.reload(); return; }
|
||||||
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
} else if (!data.logged_in) {
|
} else if (!data.logged_in) {
|
||||||
let ok = false;
|
await promptLogin();
|
||||||
while (!ok) {
|
|
||||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
|
||||||
if (!pw) continue;
|
|
||||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
|
|
||||||
else { /* failed login: caller will retry */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBuckets();
|
loadBuckets();
|
||||||
@@ -273,6 +310,7 @@ registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: '<kbd>T
|
|||||||
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: '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]')];
|
||||||
|
|||||||
+19
-8
@@ -127,15 +127,26 @@
|
|||||||
csrf() { return csrfToken; },
|
csrf() { return csrfToken; },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Generic JSON API call with automatic CSRF header. */
|
/** Generic JSON API call with automatic CSRF header.
|
||||||
|
* On 401 (session expired while the UI is open) we call the app's
|
||||||
|
* re-auth prompt once, pick up the refreshed CSRF token, and retry
|
||||||
|
* the original request so the caller's save / poll succeeds instead
|
||||||
|
* of silently returning an unauthorized error payload. */
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const doFetch = () => {
|
||||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
const res = await fetch(path, {
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
method: opts.method || 'GET',
|
return fetch(path, {
|
||||||
headers,
|
method: opts.method || 'GET',
|
||||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
headers,
|
||||||
});
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ export function animateCardRemoval(ids) {
|
|||||||
return new Promise(r => setTimeout(r, 260));
|
return new Promise(r => setTimeout(r, 260));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ghost-and-spinner overlay for cards that are being moved between
|
||||||
|
* buckets. The PATCH + reloadAll round-trip used to leave the original
|
||||||
|
* tile sitting stale in the current bucket for a couple hundred ms;
|
||||||
|
* marking them gives immediate feedback. The class comes off naturally
|
||||||
|
* on the next renderBoard() (the DOM is rebuilt from fresh state). */
|
||||||
|
export function markCardsMoving(ids) {
|
||||||
|
if (!ids || !ids.length) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
|
if (el) el.classList.add('card-moving');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._markCardsMoving = markCardsMoving;
|
||||||
|
|
||||||
/** Add a pulse class to matching board cards and strip it after the
|
/** Add a pulse class to matching board cards and strip it after the
|
||||||
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
||||||
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
||||||
@@ -486,14 +500,37 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||||
|
${Number(c.read_push) ? '<span class="flex items-center gap-1 text-t-accent" title="On your relay\'s Read list"><i class="ph ph-book-open text-xs"></i></span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
//
|
||||||
|
// Public renderBoard() is coalesced via requestAnimationFrame: multiple
|
||||||
|
// callers in the same tick (cache paint → meta paint → body paint →
|
||||||
|
// plugin-hook re-render → ...) collapse into a single actual render on
|
||||||
|
// the next frame. Dropped the "three innerHTML swaps per reload" cost
|
||||||
|
// that showed up as the "Gekringel" during boot. Callers that need the
|
||||||
|
// DOM in a specific state before doing something else can call
|
||||||
|
// renderBoardSync() explicitly — but in practice every existing caller
|
||||||
|
// just wants the board to reflect the latest state.
|
||||||
|
|
||||||
|
let rafScheduled = 0;
|
||||||
export function renderBoard() {
|
export function renderBoard() {
|
||||||
|
if (rafScheduled) return;
|
||||||
|
rafScheduled = requestAnimationFrame(() => {
|
||||||
|
rafScheduled = 0;
|
||||||
|
renderBoardSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderBoardSync() {
|
||||||
|
if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; }
|
||||||
|
renderBoardImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardImpl() {
|
||||||
// Diff state.cards against the last seen ids so we can flag arrivals
|
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||||
// as "unseen". The very first renderBoard runs with state.cards = []
|
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||||
// (initial empty state, before the API has returned), so we wait
|
// (initial empty state, before the API has returned), so we wait
|
||||||
@@ -584,10 +621,21 @@ export function renderBoard() {
|
|||||||
renderGroupBar(cards);
|
renderGroupBar(cards);
|
||||||
|
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
board.style.display = '';
|
board.style.display = 'flex';
|
||||||
board.innerHTML = `<div class="col-span-full text-center py-20">
|
board.style.alignItems = 'center';
|
||||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
board.style.justifyContent = 'center';
|
||||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
// Distinguish "genuinely empty" from "haven't heard back yet".
|
||||||
|
// Blurting "no cards found" during the initial load is scary
|
||||||
|
// on boards the user knows aren't empty.
|
||||||
|
if (!state.cardsLoaded) {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
|
||||||
|
} else {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||||
|
}
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
||||||
return;
|
return;
|
||||||
@@ -707,6 +755,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 +805,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
|
||||||
|
|||||||
@@ -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-
|
||||||
@@ -590,6 +627,7 @@ function renderBar() {
|
|||||||
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
||||||
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
||||||
if (bucketId === undefined) return;
|
if (bucketId === undefined) return;
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
||||||
window._clearSelection?.();
|
window._clearSelection?.();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
@@ -608,8 +646,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -630,6 +673,7 @@ function renderBar() {
|
|||||||
const selected = window._getSelectedIds?.() || [];
|
const selected = window._getSelectedIds?.() || [];
|
||||||
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
||||||
const card = state.cards.find(c => String(c.id) === String(cardId));
|
const card = state.cards.find(c => String(c.id) === String(cardId));
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
||||||
if (!created?.id) return;
|
if (!created?.id) return;
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
||||||
|
|||||||
+8
-170
@@ -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' },
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -408,7 +416,13 @@ export function showContextMenu(x, y, card) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
||||||
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); }));
|
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation(); hideContextMenu();
|
||||||
|
pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id });
|
||||||
|
window._markCardsMoving?.([card.id]);
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}));
|
||||||
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
||||||
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, m
|
|||||||
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
import { activateBucket } from './buckets.js';
|
||||||
|
|
||||||
const detail = $('detail');
|
const detail = $('detail');
|
||||||
const scroll = $('detail-scroll');
|
const scroll = $('detail-scroll');
|
||||||
@@ -57,6 +58,18 @@ export function openDetail(id, options = {}) {
|
|||||||
if (!card) return;
|
if (!card) return;
|
||||||
markRecent(id);
|
markRecent(id);
|
||||||
|
|
||||||
|
// Optional: switch the board to the card's bucket so the user
|
||||||
|
// sees context. Only when opened via search, wikilink, or
|
||||||
|
// programmatic call — not when the user clicked a board card
|
||||||
|
// (they're already in the right bucket). Gated by a setting
|
||||||
|
// under Behavior → Interactions.
|
||||||
|
if (card.bucket_id
|
||||||
|
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
|
||||||
|
&& state.activeBucket !== 'all'
|
||||||
|
&& String(state.activeBucket) !== String(card.bucket_id)) {
|
||||||
|
activateBucket(card.bucket_id);
|
||||||
|
}
|
||||||
|
|
||||||
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
||||||
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
||||||
|
|
||||||
@@ -72,9 +85,104 @@ export function openDetail(id, options = {}) {
|
|||||||
|
|
||||||
renderSidebar(card);
|
renderSidebar(card);
|
||||||
showContent(card);
|
showContent(card);
|
||||||
|
dismissStaleBanner();
|
||||||
detail.classList.add('open');
|
detail.classList.add('open');
|
||||||
|
|
||||||
|
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remote-update detection ──
|
||||||
|
//
|
||||||
|
// When a foreign edit arrives for the card the user is currently viewing,
|
||||||
|
// the pull replaces state.cards but the open detail view keeps rendering
|
||||||
|
// the stale object it captured on open. Instead of force-re-rendering
|
||||||
|
// under the user's finger (jarring when they're mid-read), surface a
|
||||||
|
// banner with a manual Reload button.
|
||||||
|
|
||||||
|
let staleBannerEl = null;
|
||||||
|
|
||||||
|
function dismissStaleBanner() {
|
||||||
|
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStaleBanner(fresh) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
const host = detail.querySelector('#detail-controls')?.parentElement;
|
||||||
|
if (!host) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'detail-stale-banner';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<i class="ph ph-info"></i>
|
||||||
|
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
||||||
|
<button class="detail-stale-reload">
|
||||||
|
<i class="ph ph-arrows-clockwise text-xs"></i>
|
||||||
|
<span>Reload</span>
|
||||||
|
</button>
|
||||||
|
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
`;
|
||||||
|
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
||||||
|
staleBannerEl = bar;
|
||||||
|
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
||||||
|
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
||||||
|
if (current) {
|
||||||
|
state.viewingCard = current;
|
||||||
|
renderSidebar(current);
|
||||||
|
showContent(current);
|
||||||
|
// Treat the reload as an explicit "I've now seen the new version"
|
||||||
|
// — re-fire detail.opened so collab-buckets clears the red dot
|
||||||
|
// on the board card and persists mark-card-seen to the backend.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: current.id });
|
||||||
|
}
|
||||||
|
dismissStaleBanner();
|
||||||
|
});
|
||||||
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
// SQLite stores CURRENT_TIMESTAMP as UTC without a tz marker
|
||||||
|
// ("2026-04-14 12:34:56"). Without an explicit Z, JS parses it as
|
||||||
|
// local time — off by the tz offset (2h on CEST, hence "2h ago"
|
||||||
|
// for a 30-second-old update).
|
||||||
|
const normalized = String(iso).includes('T') || /[Zz]|[+-]\d\d:?\d\d$/.test(iso)
|
||||||
|
? iso
|
||||||
|
: iso.replace(' ', 'T') + 'Z';
|
||||||
|
const t = new Date(normalized).getTime();
|
||||||
|
if (!isFinite(t)) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After every full card reload, check whether the card we're viewing is
|
||||||
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||||
|
// detail view closed in the meantime.
|
||||||
|
function checkStaleViewingCard() {
|
||||||
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewingId = state.viewingCard.id;
|
||||||
|
const fresh = state.cards.find(c => c.id == viewingId);
|
||||||
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||||
|
const old = state.viewingCard;
|
||||||
|
// Compare updated_at OR any field that might have changed. The detail
|
||||||
|
// view captures viewingCard by reference on open, so a replaced
|
||||||
|
// state.cards array (via _reloadAll) gives us a different object — if
|
||||||
|
// any visible field differs, surface the banner.
|
||||||
|
const changed =
|
||||||
|
(fresh.updated_at || '') > (old.updated_at || '') ||
|
||||||
|
(fresh.title || '') !== (old.title || '') ||
|
||||||
|
(fresh.body || '') !== (old.body || '') ||
|
||||||
|
(fresh.color || '') !== (old.color || '') ||
|
||||||
|
(fresh.tags || '') !== (old.tags || '');
|
||||||
|
if (changed) showStaleBanner(fresh);
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', checkStaleViewingCard);
|
||||||
|
|
||||||
function clearMorphGhost() {
|
function clearMorphGhost() {
|
||||||
if (morphTimer) {
|
if (morphTimer) {
|
||||||
clearTimeout(morphTimer);
|
clearTimeout(morphTimer);
|
||||||
@@ -370,6 +478,7 @@ export function closeDetail(options = {}) {
|
|||||||
closeImageFullscreen();
|
closeImageFullscreen();
|
||||||
|
|
||||||
detail.classList.remove('open', 'zen');
|
detail.classList.remove('open', 'zen');
|
||||||
|
dismissStaleBanner();
|
||||||
removeZenHandles();
|
removeZenHandles();
|
||||||
document.documentElement.classList.remove('detail-zen-open');
|
document.documentElement.classList.remove('detail-zen-open');
|
||||||
document.body.classList.remove('detail-zen-open');
|
document.body.classList.remove('detail-zen-open');
|
||||||
@@ -461,10 +570,50 @@ function updateDoneButton(card) {
|
|||||||
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReadButton(card) {
|
||||||
|
const isOn = !!Number(card?.read_push || 0);
|
||||||
|
const btn = $('detail-read-toggle');
|
||||||
|
const icon = $('detail-read-icon');
|
||||||
|
const label = $('detail-read-label');
|
||||||
|
if (!btn) return;
|
||||||
|
// Off state reuses the neutral control-bar style; on state inverts
|
||||||
|
// (accent-filled tile with dark text) so "this card sits on the relay
|
||||||
|
// right now" is visually unmistakable, matching typical toggle UIs.
|
||||||
|
btn.classList.toggle('bg-t-accent', isOn);
|
||||||
|
btn.classList.toggle('border-t-accent', isOn);
|
||||||
|
btn.classList.toggle('text-t-bg', isOn);
|
||||||
|
btn.classList.toggle('text-t-muted', !isOn);
|
||||||
|
btn.classList.toggle('hover:border-t-accent', !isOn);
|
||||||
|
btn.classList.toggle('hover:text-t-accent', !isOn);
|
||||||
|
if (icon) icon.className = (isOn ? 'ph-fill ph-book-open' : 'ph ph-book-open') + ' text-sm';
|
||||||
|
if (label) label.textContent = isOn ? 'On relay' : 'Read';
|
||||||
|
btn.title = isOn
|
||||||
|
? 'On relay — click to remove from your Read list'
|
||||||
|
: 'Push this card to your relay\'s Read list for distraction-free mobile reading';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('detail-read-toggle')?.addEventListener('click', async () => {
|
||||||
|
const card = state.viewingCard;
|
||||||
|
if (!card) return;
|
||||||
|
const next = Number(card.read_push || 0) ? 0 : 1;
|
||||||
|
try {
|
||||||
|
const updated = await api(`api/cards/${card.id}`, { method: 'PATCH', body: { read_push: next } });
|
||||||
|
if (updated && !updated.error) {
|
||||||
|
card.read_push = updated.read_push;
|
||||||
|
card.relay_uid = updated.relay_uid;
|
||||||
|
const c = state.cards.find(x => x.id == card.id);
|
||||||
|
if (c) { c.read_push = updated.read_push; c.relay_uid = updated.relay_uid; }
|
||||||
|
updateReadButton(card);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
} catch (_) { /* network error: silently ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
function showContent(card) {
|
function showContent(card) {
|
||||||
$('detail-color').style.background = card.color;
|
$('detail-color').style.background = card.color;
|
||||||
titleEl.textContent = card.title;
|
titleEl.textContent = card.title;
|
||||||
updateDoneButton(card);
|
updateDoneButton(card);
|
||||||
|
updateReadButton(card);
|
||||||
const doneCorner = $('detail-done-corner');
|
const doneCorner = $('detail-done-corner');
|
||||||
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
||||||
bodyEl.innerHTML = renderMd(card.body);
|
bodyEl.innerHTML = renderMd(card.body);
|
||||||
|
|||||||
+33
-13
@@ -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');
|
||||||
@@ -225,8 +226,18 @@ export function openModal(card = null) {
|
|||||||
inpTitle.value = card ? card.title : (isJournal ? today : '');
|
inpTitle.value = card ? card.title : (isJournal ? today : '');
|
||||||
body.value = card ? card.body : '';
|
body.value = card ? card.body : '';
|
||||||
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
||||||
|
// New appointments (#termin / #geburtstag pre-filled from the
|
||||||
|
// calendar's day-popup template) must never inherit the active
|
||||||
|
// bucket — they belong to a calendar, not a kanban column. For
|
||||||
|
// every other new card, inherit the active real bucket if we're
|
||||||
|
// in one so the user doesn't have to re-pick it.
|
||||||
|
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
|
||||||
|
// from the calendar day-popup — both count as "new", so key on
|
||||||
|
// card?.id not the card ref itself.
|
||||||
|
const isNewAppointment = !card?.id && /#(termin|appointment|geburtstag|birthday)\b/.test(body.value);
|
||||||
inpBucket.value = card?.bucket_id ? card.bucket_id
|
inpBucket.value = card?.bucket_id ? card.bucket_id
|
||||||
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : '');
|
: (isNewAppointment ? ''
|
||||||
|
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
|
||||||
selectColor(card ? card.color : '#38bdf8');
|
selectColor(card ? card.color : '#38bdf8');
|
||||||
setDueDate(card?.due_date || '');
|
setDueDate(card?.due_date || '');
|
||||||
updateDoneToggle();
|
updateDoneToggle();
|
||||||
@@ -238,7 +249,7 @@ export function openModal(card = null) {
|
|||||||
updateActiveLine();
|
updateActiveLine();
|
||||||
|
|
||||||
// Check for unsaved draft (newer than what we just loaded).
|
// Check for unsaved draft (newer than what we just loaded).
|
||||||
// Skipped during test runs — tests would block on the confirm dialog.
|
// Skipped during test runs — tests would block on the dialog.
|
||||||
const draft = loadDraft(card?.id);
|
const draft = loadDraft(card?.id);
|
||||||
if (draft && !window._testRunning) {
|
if (draft && !window._testRunning) {
|
||||||
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
||||||
@@ -248,18 +259,27 @@ export function openModal(card = null) {
|
|||||||
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
||||||
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
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 ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
|
||||||
const restore = window.confirm(`Unsaved draft from ${ageText} found.\n\nRestore it?`);
|
const cardId = card?.id;
|
||||||
if (restore) {
|
// Async confirm — fires after openModal returns
|
||||||
inpTitle.value = draft.title || '';
|
(async () => {
|
||||||
body.value = draft.body || '';
|
const restore = await showDialog({
|
||||||
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
title: 'unsaved draft',
|
||||||
updatePreview();
|
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
||||||
} else {
|
okLabel: 'Restore',
|
||||||
clearDraft(card?.id);
|
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) {
|
} else if (draft && window._testRunning) {
|
||||||
// During tests, silently discard any draft so it doesn't accumulate
|
|
||||||
clearDraft(card?.id);
|
clearDraft(card?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +512,7 @@ async function doSave(close, { animate = true } = {}) {
|
|||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
// Auto-sync if card has #termin or #geburtstag
|
// Auto-sync if card has #termin or #geburtstag
|
||||||
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
|
if (data.body && /#(termin|appointment|geburtstag|birthday)\b/.test(data.body)) {
|
||||||
window._triggerSync?.();
|
window._triggerSync?.();
|
||||||
}
|
}
|
||||||
if (close) {
|
if (close) {
|
||||||
|
|||||||
@@ -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());
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// ── Sync Orchestration ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Owns the single source of truth for "time to sync". Every scheduled
|
||||||
|
// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
|
||||||
|
// the chat plugin — hangs off the same interval via the `sync.tick` hook
|
||||||
|
// instead of running its own setInterval. That way:
|
||||||
|
//
|
||||||
|
// - The header Sync button triggers ALL syncs (emits `sync.tick`).
|
||||||
|
// - The Autosync toggle pauses ALL syncs (stops the single timer).
|
||||||
|
// - The configurable interval changes ALL cadences at once.
|
||||||
|
//
|
||||||
|
// This module also owns the autosync button UI, the sync-event toast
|
||||||
|
// feed, and the relay settings panel (which hosts the interval input).
|
||||||
|
//
|
||||||
|
// Hooks emitted:
|
||||||
|
// sync.tick — fire all pollers now
|
||||||
|
// sync.pulse — pulse the header dot (subset of tick)
|
||||||
|
// sync.interval.changed — restart timers with new cadence
|
||||||
|
// autosync.changed — user paused/resumed scheduled polling
|
||||||
|
//
|
||||||
|
// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
|
||||||
|
// in caldav.js. This file has no CalDAV icons/colors concerns.
|
||||||
|
|
||||||
|
import { $, api, getCsrf } from './util.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
const syncStatus = $('caldav-sync-status');
|
||||||
|
|
||||||
|
// ── Manual "Sync now" trigger ───────────────────────────────────
|
||||||
|
// Fired by the header Sync button, the CalDAV panel "sync now" button,
|
||||||
|
// and the `autoSync()` throttled helper. Always runs regardless of the
|
||||||
|
// autosync-paused setting — the user pressed a button, we honor that.
|
||||||
|
// Fans out to every plugin poller via `sync.tick`.
|
||||||
|
export async function triggerSync() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
const btn = $('cal-sync');
|
||||||
|
if (btn) btn.style.opacity = '0.5';
|
||||||
|
const headerIcon = $('header-sync-icon');
|
||||||
|
if (headerIcon) headerIcon.classList.add('card-spinner');
|
||||||
|
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
||||||
|
|
||||||
|
const res = await fetch('api/caldav/sync', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
||||||
|
|
||||||
|
if (btn) btn.style.opacity = '';
|
||||||
|
|
||||||
|
// Also poll relay for mail-style messages. Header spinner stays on
|
||||||
|
// until this completes too.
|
||||||
|
const relayRes = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
||||||
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
|
||||||
|
// Fan out to plugin pollers (collab-buckets, chat, …). They run
|
||||||
|
// concurrently with us — no await needed here.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const s = res.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const caldavStats = { pushed: s.pushed || 0, pulled: s.pulled || 0, deleted: s.deleted || 0 };
|
||||||
|
const relayStats = { pulled: rp };
|
||||||
|
const allLog = res.log || [];
|
||||||
|
const hasErrors = allLog.some(l => l.status === 'error');
|
||||||
|
const msg = `pushed:${caldavStats.pushed} pulled:${caldavStats.pulled + rp} del:${caldavStats.deleted}${rp ? ' relay:' + rp : ''}`;
|
||||||
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showLog(allLog);
|
||||||
|
showSyncNotification('CalDAV', 'ph-cloud-arrow-down', caldavStats);
|
||||||
|
showSyncNotification('Relay', 'ph-broadcast', relayStats);
|
||||||
|
} else {
|
||||||
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||||
|
}
|
||||||
|
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV log display inside the Settings → CalDAV panel. Lives here
|
||||||
|
// (not in caldav.js) because triggerSync writes to it.
|
||||||
|
function showLog(log) {
|
||||||
|
const logEl = $('caldav-log');
|
||||||
|
if (!logEl || !log?.length) return;
|
||||||
|
logEl.classList.remove('hidden');
|
||||||
|
logEl.innerHTML = log.map(l =>
|
||||||
|
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-sync throttle ──────────────────────────────────────────
|
||||||
|
// Used by the CalDAV module when the user saves a card with #termin or
|
||||||
|
// #geburtstag — we want to fire a sync immediately but not storm the
|
||||||
|
// server if they paste a big batch. 60s cooldown between auto-fires.
|
||||||
|
let lastAutoSync = 0;
|
||||||
|
export function autoSync() {
|
||||||
|
if (window._testRunning) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAutoSync < 60000) return;
|
||||||
|
lastAutoSync = now;
|
||||||
|
triggerSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync notifications ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Per-source sync toast. Caller picks the label ("CalDAV" vs "Relay")
|
||||||
|
* and icon so the user knows which pipeline just finished, instead of
|
||||||
|
* the old always-CalDAV banner that lumped relay traffic in silently. */
|
||||||
|
let syncNoteOffset = 0;
|
||||||
|
function showSyncNotification(label, icon, stats) {
|
||||||
|
const s = stats || {};
|
||||||
|
const parts = [];
|
||||||
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
||||||
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
||||||
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
||||||
|
if (!parts.length) return;
|
||||||
|
|
||||||
|
const bottom = 60 + syncNoteOffset * 48;
|
||||||
|
syncNoteOffset++;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;`;
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ${icon}"></i>${label}: ${parts.join(', ')}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => { note.remove(); syncNoteOffset = Math.max(0, syncNoteOffset - 1); }, 350);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One toast per incoming sync event: "alice synced: Card Title".
|
||||||
|
// Stacks downward so a burst of multiple doesn't overlay each other.
|
||||||
|
let syncEventToastOffset = 0;
|
||||||
|
function showSyncEventToast(ev) {
|
||||||
|
const body = String(ev?.body || '').trim();
|
||||||
|
if (!body) return;
|
||||||
|
const bottom = 110 + syncEventToastOffset * 48;
|
||||||
|
syncEventToastOffset++;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
|
||||||
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
||||||
|
document.body.appendChild(note);
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
note.remove();
|
||||||
|
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
|
||||||
|
}, 350);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainSyncEvents(relayRes) {
|
||||||
|
if (relayRes?.sync_debug) {
|
||||||
|
console.debug('[sync-events]', relayRes.sync_debug);
|
||||||
|
}
|
||||||
|
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
|
||||||
|
for (const ev of events) showSyncEventToast(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduled background pull ───────────────────────────────────
|
||||||
|
// Internal helper — the CalDAV + relay mail side of a tick. Plugin
|
||||||
|
// pollers run concurrently via the `sync.tick` hook.
|
||||||
|
async function backgroundPull() {
|
||||||
|
if (state.bgSyncRunning) return;
|
||||||
|
state.bgSyncRunning = true;
|
||||||
|
try {
|
||||||
|
const headers = { 'X-CSRF-Token': getCsrf() };
|
||||||
|
const [res, relayRes] = await Promise.all([
|
||||||
|
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||||
|
]);
|
||||||
|
const s = res?.stats || {};
|
||||||
|
const rp = relayRes?.pulled || 0;
|
||||||
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
||||||
|
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
||||||
|
await window._reloadAll?.();
|
||||||
|
showSyncNotification(total);
|
||||||
|
}
|
||||||
|
drainSyncEvents(relayRes);
|
||||||
|
} catch {} finally {
|
||||||
|
state.bgSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central tick: the one and only scheduled sync heartbeat. Fires the
|
||||||
|
// CalDAV+relay pull and emits `sync.tick` for every other poller.
|
||||||
|
function tick() {
|
||||||
|
pulseAutosyncDot();
|
||||||
|
backgroundPull();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configurable sync interval ────────────────────────────────
|
||||||
|
// One setting drives every scheduled poller. Changed via the Relay
|
||||||
|
// settings panel; emits `sync.interval.changed` so the central timer
|
||||||
|
// restarts on the new cadence without a page reload.
|
||||||
|
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
|
||||||
|
const SYNC_INTERVAL_DEFAULT = 15;
|
||||||
|
const SYNC_INTERVAL_MIN = 5;
|
||||||
|
const SYNC_INTERVAL_MAX = 3600;
|
||||||
|
export function getSyncIntervalSec() {
|
||||||
|
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
|
||||||
|
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
|
||||||
|
}
|
||||||
|
export function setSyncIntervalSec(sec) {
|
||||||
|
const n = parseInt(sec);
|
||||||
|
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
|
||||||
|
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
|
||||||
|
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autosync on/off toggle ────────────────────────────────────
|
||||||
|
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
|
||||||
|
export function isAutoSyncEnabled() {
|
||||||
|
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
|
||||||
|
}
|
||||||
|
export function setAutoSyncEnabled(on) {
|
||||||
|
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
|
||||||
|
if (on) startBackgroundSync();
|
||||||
|
else stopBackgroundSync();
|
||||||
|
updateAutosyncButton();
|
||||||
|
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central tick timer ────────────────────────────────────────
|
||||||
|
let tickInterval = null;
|
||||||
|
export function startBackgroundSync() {
|
||||||
|
if (tickInterval) return;
|
||||||
|
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
|
||||||
|
tick();
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
export function stopBackgroundSync() {
|
||||||
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||||
|
updateAutosyncButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the central timer when the user changes the interval.
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval);
|
||||||
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Header autosync button ────────────────────────────────────
|
||||||
|
function updateAutosyncButton() {
|
||||||
|
const btn = $('header-autosync');
|
||||||
|
if (!btn) return;
|
||||||
|
const on = isAutoSyncEnabled();
|
||||||
|
btn.dataset.autosync = on ? 'on' : 'off';
|
||||||
|
btn.setAttribute('data-tip', on
|
||||||
|
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
|
||||||
|
: 'auto-sync: paused — click to resume');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse the dot once. Rip and re-add the class after a reflow so
|
||||||
|
// re-triggering mid-animation restarts cleanly.
|
||||||
|
export function pulseAutosyncDot() {
|
||||||
|
const dot = $('header-autosync-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.remove('pulsing');
|
||||||
|
void dot.offsetWidth;
|
||||||
|
dot.classList.add('pulsing');
|
||||||
|
}
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
|
||||||
|
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
|
||||||
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
|
||||||
|
updateAutosyncButton();
|
||||||
|
|
||||||
|
// ── Sync-adjacent button wiring ───────────────────────────────
|
||||||
|
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('header-sync')?.addEventListener('click', () => triggerSync());
|
||||||
|
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
||||||
|
|
||||||
|
// ── Relay settings panel ──────────────────────────────────────
|
||||||
|
// The relay panel lives in Settings → Relay. It holds the URL /
|
||||||
|
// username / shared-key inputs plus the shared sync-interval number
|
||||||
|
// input that drives every scheduled poller.
|
||||||
|
|
||||||
|
function setRelayStatus(text, kind = 'info') {
|
||||||
|
const el = $('relay-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.borderColor = '';
|
||||||
|
el.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.textContent = text;
|
||||||
|
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
||||||
|
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
||||||
|
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('relay-save')?.addEventListener('click', async () => {
|
||||||
|
const body = {
|
||||||
|
url: $('relay-url')?.value || '',
|
||||||
|
username: $('relay-username')?.value || '',
|
||||||
|
key: $('relay-key')?.value || '',
|
||||||
|
};
|
||||||
|
setRelayStatus('saving...');
|
||||||
|
await api('api/relay/settings', { method: 'POST', body });
|
||||||
|
setRelayStatus('saved', 'success');
|
||||||
|
setTimeout(() => setRelayStatus(''), 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('relay-test')?.addEventListener('click', async () => {
|
||||||
|
setRelayStatus('testing...');
|
||||||
|
const res = await fetch('api/relay/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||||
|
body: JSON.stringify({ strict: true }),
|
||||||
|
}).then(r => r.json()).catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
||||||
|
if (res.pulled) await window._reloadAll?.();
|
||||||
|
drainSyncEvents(res);
|
||||||
|
} else {
|
||||||
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
||||||
|
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadRelaySettings() {
|
||||||
|
const s = await api('api/relay/settings');
|
||||||
|
if (!s) return;
|
||||||
|
const urlInput = $('relay-url');
|
||||||
|
const userInput = $('relay-username');
|
||||||
|
const keyInput = $('relay-key');
|
||||||
|
if (urlInput) urlInput.value = s.url || '';
|
||||||
|
if (userInput) userInput.value = s.username || '';
|
||||||
|
if (keyInput) {
|
||||||
|
// The server no longer echoes the stored secret. Leave the field
|
||||||
|
// blank; placeholder tells the user whether one is set. An empty
|
||||||
|
// save is treated as "field untouched" server-side.
|
||||||
|
keyInput.value = '';
|
||||||
|
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
|
||||||
|
}
|
||||||
|
// Sync-interval slider lives in the Relay panel but drives every
|
||||||
|
// poller. Snap points span 5s..1h; live label updates on drag,
|
||||||
|
// persisted value commits on release.
|
||||||
|
const intervalInp = $('setting-sync-interval');
|
||||||
|
const intervalVal = $('setting-sync-interval-val');
|
||||||
|
if (intervalInp) {
|
||||||
|
const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
|
||||||
|
const fmt = (s) => {
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s === 3600) return '1h';
|
||||||
|
const m = Math.floor(s / 60), r = s % 60;
|
||||||
|
return r ? `${m}m ${r}s` : `${m}m`;
|
||||||
|
};
|
||||||
|
const idxFor = (sec) => {
|
||||||
|
let best = 0, bestDiff = Infinity;
|
||||||
|
for (let i = 0; i < STEPS.length; i++) {
|
||||||
|
const d = Math.abs(STEPS[i] - sec);
|
||||||
|
if (d < bestDiff) { bestDiff = d; best = i; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
intervalInp.max = String(STEPS.length - 1);
|
||||||
|
const sync = (idx) => {
|
||||||
|
const sec = STEPS[idx];
|
||||||
|
if (intervalVal) intervalVal.textContent = fmt(sec);
|
||||||
|
return sec;
|
||||||
|
};
|
||||||
|
intervalInp.value = String(idxFor(getSyncIntervalSec()));
|
||||||
|
sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.oninput = () => sync(parseInt(intervalInp.value));
|
||||||
|
intervalInp.onchange = () => {
|
||||||
|
const sec = sync(parseInt(intervalInp.value));
|
||||||
|
setSyncIntervalSec(sec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,13 @@ const summary = $('tasks-summary');
|
|||||||
const countBadge = $('tasks-open-count');
|
const countBadge = $('tasks-open-count');
|
||||||
|
|
||||||
function collectTasks() {
|
function collectTasks() {
|
||||||
|
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
|
||||||
const all = [];
|
const all = [];
|
||||||
for (const card of state.cards) {
|
for (const card of state.cards) {
|
||||||
|
if (!showShopping) {
|
||||||
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||||
|
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
|
||||||
|
}
|
||||||
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
||||||
let m, idx = 0;
|
let m, idx = 0;
|
||||||
while ((m = re.exec(card.body || '')) !== null) {
|
while ((m = re.exec(card.body || '')) !== null) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -425,7 +411,7 @@ $('settings-pw-save')?.addEventListener('click', async () => {
|
|||||||
|
|
||||||
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw.length < 10) { if (status) { status.textContent = 'min 10 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
|
|
||||||
const res = await fetch('api/auth/password', {
|
const res = await fetch('api/auth/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -464,6 +450,8 @@ function initBehaviorSettings() {
|
|||||||
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
||||||
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
||||||
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
||||||
|
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
|
||||||
|
'setting-tasks-show-shopping': { key: 'hyprcrd-tasks-show-shopping', def: '0' },
|
||||||
};
|
};
|
||||||
for (const [id, cfg] of Object.entries(selects)) {
|
for (const [id, cfg] of Object.entries(selects)) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
|
|
||||||
const SHOW_DELAY = 400;
|
const SHOW_DELAY = 400;
|
||||||
const HIDE_DELAY = 100;
|
const HIDE_DELAY = 100;
|
||||||
|
const MAX_LIFE = 5000; // watchdog: tooltip never outlives this
|
||||||
const OFFSET = 8;
|
const OFFSET = 8;
|
||||||
|
|
||||||
let tipEl = null;
|
let tipEl = null;
|
||||||
let showTimer = null;
|
let showTimer = null;
|
||||||
let hideTimer = null;
|
let hideTimer = null;
|
||||||
|
let lifeTimer = null;
|
||||||
let currentTarget = null;
|
let currentTarget = null;
|
||||||
|
|
||||||
function create() {
|
function create() {
|
||||||
@@ -63,10 +65,18 @@ function show(target) {
|
|||||||
tipEl.style.top = top + 'px';
|
tipEl.style.top = top + 'px';
|
||||||
tipEl.style.left = left + 'px';
|
tipEl.style.left = left + 'px';
|
||||||
tipEl.style.visibility = '';
|
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() {
|
function hide() {
|
||||||
currentTarget = null;
|
currentTarget = null;
|
||||||
|
clearTimeout(lifeTimer);
|
||||||
if (tipEl) tipEl.style.display = 'none';
|
if (tipEl) tipEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +101,20 @@ document.addEventListener('pointerleave', onLeave, true);
|
|||||||
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
|
document.addEventListener('pointerdown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
|
document.addEventListener('keydown', () => { clearTimeout(showTimer); hide(); }, true);
|
||||||
document.addEventListener('scroll', () => { 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(); } });
|
||||||
@@ -15,13 +15,23 @@ export function setCsrf(token) { csrfToken = token; }
|
|||||||
export function getCsrf() { return csrfToken; }
|
export function getCsrf() { return csrfToken; }
|
||||||
|
|
||||||
export async function api(path, opts = {}) {
|
export async function api(path, opts = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const doFetch = () => {
|
||||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
const res = await fetch(path, {
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
headers,
|
return fetch(path, {
|
||||||
...opts,
|
headers,
|
||||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
...opts,
|
||||||
});
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
// Session expired mid-use: prompt for re-login, then retry once. Avoid
|
||||||
|
// recursion on auth endpoints themselves — promptLogin handles its own
|
||||||
|
// fetches directly.
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookmarks Plugin: backend.
|
||||||
|
*
|
||||||
|
* Single endpoint that fetches a URL's <title> and favicon so the
|
||||||
|
* frontend doesn't have to deal with CORS. Light and stateless —
|
||||||
|
* the bookmarks themselves live in the card body (JSON blob).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/bookmarks/fetch-meta?url=... → { title, favicon }
|
||||||
|
*/
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return;
|
||||||
|
|
||||||
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
|
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'valid url required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
|
||||||
|
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'only http/https URLs allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!hyprcrdUrlHostIsPublic($url)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'URL host is not publicly reachable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$html = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$title = '';
|
||||||
|
if ($html && $status >= 200 && $status < 400) {
|
||||||
|
// Extract <title>
|
||||||
|
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
|
||||||
|
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
}
|
||||||
|
// og:title fallback
|
||||||
|
if ($title === '' && preg_match('/<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) {
|
||||||
|
$title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favicon: resolve the URL, then download the bytes and return a
|
||||||
|
// self-contained data: URI so the card body is fully offline-capable
|
||||||
|
// and opening the overlay doesn't leak requests to every bookmarked
|
||||||
|
// site. Caps at 32 KB — anything bigger is probably not an icon.
|
||||||
|
$parsed = parse_url($finalUrl);
|
||||||
|
$origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||||
|
$faviconUrl = $origin . '/favicon.ico';
|
||||||
|
if ($html && preg_match('/<link[^>]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) {
|
||||||
|
$href = $m[1];
|
||||||
|
if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href;
|
||||||
|
elseif (str_starts_with($href, '/')) $href = $origin . $href;
|
||||||
|
elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href;
|
||||||
|
$faviconUrl = $href;
|
||||||
|
}
|
||||||
|
|
||||||
|
$faviconData = '';
|
||||||
|
$faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME));
|
||||||
|
$favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl);
|
||||||
|
$ch2 = $favCh ? curl_init($faviconUrl) : null;
|
||||||
|
if ($ch2) curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 3,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
|
]);
|
||||||
|
if ($ch2) hyprcrdApplySafeCurlOpts($ch2);
|
||||||
|
$favBytes = $ch2 ? curl_exec($ch2) : false;
|
||||||
|
$favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0;
|
||||||
|
$favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon';
|
||||||
|
if ($ch2) curl_close($ch2);
|
||||||
|
|
||||||
|
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
||||||
|
$mime = preg_replace('/;.*$/', '', $favMime);
|
||||||
|
$faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'url' => $finalUrl,
|
||||||
|
'title' => mb_substr($title, 0, 200),
|
||||||
|
'favicon' => $faviconData,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/* ── Bookmarks plugin ── */
|
||||||
|
|
||||||
|
/* Board card override: the MutationObserver replaces the card's
|
||||||
|
innerHTML with a coloured tile (card.color as bg) showing a
|
||||||
|
centered link icon + the title. No body preview, no tags, no
|
||||||
|
metadata — just a clean, recognisable tile. */
|
||||||
|
.board-card.bm-card {
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bm-card-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
.bm-card-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 14px 8px;
|
||||||
|
}
|
||||||
|
.bm-card-icon {
|
||||||
|
font-size: 42px;
|
||||||
|
color: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.bm-card-title {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(0,0,0,0.55);
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.bm-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.bm-card-meta i { font-size: 10px; margin-right: 2px; }
|
||||||
|
|
||||||
|
/* Overlay shell */
|
||||||
|
.bookmarks-shell {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bm-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Column width slider — full width */
|
||||||
|
.bookmarks-slider-row {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bookmarks-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--t-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bookmarks-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
.bookmarks-slider::-moz-range-thumb {
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--t-accent); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column container — horizontal scroll, wheel redirected by JS */
|
||||||
|
.bookmarks-columns {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.bookmarks-columns::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Single column */
|
||||||
|
.bm-col {
|
||||||
|
flex: 0 0 var(--bm-col-w, 220px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bm-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-name {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-name:hover { color: var(--t-accent); }
|
||||||
|
.bm-col-header .bm-col-add {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.bm-col-header .bm-col-add:hover { color: var(--t-accent); }
|
||||||
|
|
||||||
|
.bm-col-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark item */
|
||||||
|
.bm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--t-raised, var(--t-surface));
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card, 3px);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bm-item:hover {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 6%, var(--t-surface));
|
||||||
|
}
|
||||||
|
.bm-item .bm-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.bm-item .bm-favicon-placeholder {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bm-item .bm-item-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bm-item:hover .bm-item-title { color: var(--t-bright); }
|
||||||
|
.bm-item.dragging { opacity: 0.4; }
|
||||||
|
.bm-item.bm-drop-above {
|
||||||
|
box-shadow: 0 -2px 0 0 var(--t-accent);
|
||||||
|
}
|
||||||
|
.bm-item.bm-drop-below {
|
||||||
|
box-shadow: 0 2px 0 0 var(--t-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
|
.bm-ctx {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 300;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
border-radius: var(--ui-radius-modal, 4px);
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.bm-ctx button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.08s, color 0.08s;
|
||||||
|
}
|
||||||
|
.bm-ctx button:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||||
|
.bm-ctx button.danger:hover { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Empty column state */
|
||||||
|
.bm-col-empty {
|
||||||
|
padding: 16px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- Bookmarks overlay. Opens when the user clicks a #bookmarks card
|
||||||
|
on the board (instead of the normal detail view). Column-based
|
||||||
|
layout; each column is a topic, each item is a bookmark with
|
||||||
|
favicon + title. -->
|
||||||
|
<div id="bookmarks-overlay" class="overlay fixed inset-0 z-[80]" style="background:var(--t-overlay)">
|
||||||
|
<div class="bookmarks-shell">
|
||||||
|
<div class="bookmarks-header">
|
||||||
|
<i class="ph ph-link text-t-accent text-base"></i>
|
||||||
|
<span id="bookmarks-title" class="font-pixel text-sm text-t-accent flex-1 truncate cursor-pointer hover:text-t-bright transition-colors" data-tip="click to rename"></span>
|
||||||
|
<button id="bookmarks-add-btn" class="bm-btn" data-tip="new bookmark (paste URL)"><i class="ph ph-plus text-sm"></i></button>
|
||||||
|
<button id="bookmarks-col-add" class="bm-btn" data-tip="new column"><i class="ph ph-columns-plus-right text-sm"></i></button>
|
||||||
|
<button id="bookmarks-close" class="bm-btn hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="bookmarks-slider-row">
|
||||||
|
<input type="range" id="bookmarks-col-width" min="140" max="400" step="10" value="220" class="bookmarks-slider">
|
||||||
|
</div>
|
||||||
|
<div id="bookmarks-columns" class="bookmarks-columns"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
// ── Bookmarks Plugin ──
|
||||||
|
//
|
||||||
|
// A bookmark list is a card tagged #bookmarks. Its body is a JSON
|
||||||
|
// blob: { type:"bookmarks", columns:[{name, items:[{url,title,favicon}]}], columnWidth }.
|
||||||
|
// On the board the card renders as a coloured tile with a link icon
|
||||||
|
// and no body preview. Clicking opens a column-based overlay; each
|
||||||
|
// item is a small box with favicon + title. Click → open URL.
|
||||||
|
//
|
||||||
|
// Phase 1+2: skeleton, board decoration, overlay (read + write),
|
||||||
|
// add/delete/rename, clipboard paste + server-side meta fetch.
|
||||||
|
|
||||||
|
const hyprcrd = window.hyprcrd;
|
||||||
|
if (!hyprcrd) throw new Error('[bookmarks] window.hyprcrd missing');
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG = 'bookmarks';
|
||||||
|
let activeCardId = null; // card id currently open in the overlay
|
||||||
|
let data = null; // parsed JSON body of the active card
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isBookmarkCard(card) {
|
||||||
|
return (card.tags || '').split(',').map(s => s.trim()).includes(TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyData() {
|
||||||
|
return { type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(body) {
|
||||||
|
const text = (body || '').trim();
|
||||||
|
// Full string might be pure JSON (legacy) or JSON + trailing
|
||||||
|
// "\n\n#bookmarks" tag line that extractTags needs on the server.
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
|
||||||
|
} catch {}
|
||||||
|
// Strip trailing tag line and retry.
|
||||||
|
const jsonEnd = text.lastIndexOf('}');
|
||||||
|
if (jsonEnd >= 0) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(text.substring(0, jsonEnd + 1));
|
||||||
|
if (d?.type === 'bookmarks' && Array.isArray(d.columns)) return d;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return emptyData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrl(s) {
|
||||||
|
return /^https?:\/\/.+/i.test((s || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCardLink(s) {
|
||||||
|
return /^card:\d+$/i.test((s || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCardId(s) {
|
||||||
|
const m = /^card:(\d+)$/i.exec((s || '').trim());
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!activeCardId || !data) return;
|
||||||
|
// The trailing #bookmarks tag is essential: the server's
|
||||||
|
// extractTags() parses hashtags from the body text to populate
|
||||||
|
// the `tags` column. Without it, isBookmarkCard (which checks
|
||||||
|
// tags, not body) wouldn't recognise the card on meta-only
|
||||||
|
// loads where body is still null.
|
||||||
|
const body = JSON.stringify(data) + '\n\n#bookmarks';
|
||||||
|
await hyprcrd.api(`api/cards/${activeCardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { body },
|
||||||
|
});
|
||||||
|
// Keep the local state in sync so the next openOverlay (which
|
||||||
|
// reads from state.cards, not the server) sees the latest body.
|
||||||
|
const local = (window.state?.cards || []).find(c => c.id == activeCardId);
|
||||||
|
if (local) local.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board card decoration ────────────────────────────────────────
|
||||||
|
// MutationObserver on #board: any card whose tags include "bookmarks"
|
||||||
|
// gets the .bm-card class + a link icon. Click is intercepted to
|
||||||
|
// open the overlay instead of the detail view.
|
||||||
|
|
||||||
|
function decorateBoard() {
|
||||||
|
const board = $('board');
|
||||||
|
if (!board) return;
|
||||||
|
const cards = window.state?.cards || [];
|
||||||
|
board.querySelectorAll('.board-card[data-id]').forEach(el => {
|
||||||
|
const card = cards.find(c => String(c.id) === el.dataset.id);
|
||||||
|
if (!card || !isBookmarkCard(card)) return;
|
||||||
|
if (el.dataset.bmDone) return;
|
||||||
|
el.dataset.bmDone = '1';
|
||||||
|
el.classList.add('bm-card');
|
||||||
|
// Replace the entire card content with a coloured tile
|
||||||
|
// showing just a big link icon and the title. The original
|
||||||
|
// innerHTML (bar + body preview + tags + meta) is noise for
|
||||||
|
// a bookmark list card.
|
||||||
|
const color = card.color || '#38bdf8';
|
||||||
|
el.style.background = `color-mix(in srgb, ${color} 70%, var(--t-surface))`;
|
||||||
|
el.style.borderColor = color;
|
||||||
|
const date = (card.updated_at || card.created_at || '').substring(0, 10);
|
||||||
|
const bucket = card.bucket_name || '';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="bm-card-inner">
|
||||||
|
<div class="bm-card-center">
|
||||||
|
<i class="ph ph-link bm-card-icon"></i>
|
||||||
|
<span class="bm-card-title">${esc(card.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bm-card-meta">
|
||||||
|
<span><i class="ph ph-clock"></i>${esc(date)}</span>
|
||||||
|
${bucket ? `<span><i class="ph ph-folder"></i>${esc(bucket)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
openOverlay(card.id);
|
||||||
|
}, true);
|
||||||
|
el.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
||||||
|
openOverlay(card.id);
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-decorate after every board render.
|
||||||
|
new MutationObserver(decorateBoard).observe(
|
||||||
|
$('board') || document.body,
|
||||||
|
{ childList: true, subtree: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Overlay ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const overlay = $('bookmarks-overlay');
|
||||||
|
const colsEl = $('bookmarks-columns');
|
||||||
|
const slider = $('bookmarks-col-width');
|
||||||
|
|
||||||
|
async function openOverlay(cardId) {
|
||||||
|
activeCardId = cardId;
|
||||||
|
let card = (window.state?.cards || []).find(c => c.id == cardId);
|
||||||
|
if (!card) return;
|
||||||
|
// Body might still be null if Phase-2 hasn't loaded it yet.
|
||||||
|
// Fetch it explicitly so we don't clobber the stored JSON
|
||||||
|
// (including columnWidth) with emptyData() defaults.
|
||||||
|
if (card.body == null) {
|
||||||
|
const full = await hyprcrd.api('api/cards?ids=' + cardId);
|
||||||
|
if (Array.isArray(full) && full[0]) {
|
||||||
|
const idx = (window.state?.cards || []).findIndex(c => c.id == cardId);
|
||||||
|
if (idx >= 0) window.state.cards[idx] = full[0];
|
||||||
|
card = full[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('bookmarks-title').textContent = card.title || 'Bookmarks';
|
||||||
|
data = parseBody(card.body);
|
||||||
|
if (slider) slider.value = data.columnWidth || 220;
|
||||||
|
applyColumnWidth();
|
||||||
|
renderColumns();
|
||||||
|
overlay?.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverlay() {
|
||||||
|
overlay?.classList.remove('open');
|
||||||
|
activeCardId = null;
|
||||||
|
data = null;
|
||||||
|
hideCtx();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnWidth() {
|
||||||
|
const w = slider ? parseInt(slider.value) : 220;
|
||||||
|
colsEl?.style.setProperty('--bm-col-w', w + 'px');
|
||||||
|
if (data) data.columnWidth = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumns() {
|
||||||
|
if (!colsEl || !data) return;
|
||||||
|
colsEl.innerHTML = '';
|
||||||
|
data.columns.forEach((col, ci) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'bm-col';
|
||||||
|
div.dataset.col = ci;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'bm-col-header';
|
||||||
|
hdr.innerHTML = `
|
||||||
|
<span class="bm-col-name" title="${esc(col.name)}">${esc(col.name)}</span>
|
||||||
|
<button class="bm-col-add" data-col="${ci}" title="add bookmark"><i class="ph ph-plus"></i></button>
|
||||||
|
`;
|
||||||
|
hdr.querySelector('.bm-col-name').addEventListener('click', () => renameColumn(ci));
|
||||||
|
hdr.querySelector('.bm-col-name').addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showColCtx(ci, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
hdr.querySelector('.bm-col-add').addEventListener('click', () => addBookmark(ci));
|
||||||
|
div.appendChild(hdr);
|
||||||
|
|
||||||
|
// Items
|
||||||
|
const items = document.createElement('div');
|
||||||
|
items.className = 'bm-col-items';
|
||||||
|
items.dataset.col = ci;
|
||||||
|
if (!col.items.length) {
|
||||||
|
items.innerHTML = '<div class="bm-col-empty">no bookmarks</div>';
|
||||||
|
} else {
|
||||||
|
col.items.forEach((it, ii) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'bm-item';
|
||||||
|
el.dataset.col = ci;
|
||||||
|
el.dataset.idx = ii;
|
||||||
|
el.draggable = true;
|
||||||
|
const cardId = parseCardId(it.url);
|
||||||
|
const isCard = !!cardId;
|
||||||
|
let fav;
|
||||||
|
if (isCard) {
|
||||||
|
// Card link: show a card icon instead of a web favicon.
|
||||||
|
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
|
||||||
|
const linkedColor = linked?.color || 'var(--t-accent)';
|
||||||
|
fav = `<i class="ph ph-card bm-favicon-placeholder" style="color:${esc(linkedColor)}"></i>`;
|
||||||
|
} else {
|
||||||
|
fav = it.favicon
|
||||||
|
? `<img class="bm-favicon" src="${esc(it.favicon)}" onerror="this.outerHTML='<i class=\\'ph ph-globe bm-favicon-placeholder\\'></i>'">`
|
||||||
|
: '<i class="ph ph-globe bm-favicon-placeholder"></i>';
|
||||||
|
}
|
||||||
|
el.innerHTML = `${fav}<span class="bm-item-title" title="${esc(it.title || it.url)}">${esc(it.title || it.url)}</span>`;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (isCard) {
|
||||||
|
// Check if the linked card is itself a bookmark list.
|
||||||
|
const linked = (window.state?.cards || []).find(c => String(c.id) === cardId);
|
||||||
|
if (linked && isBookmarkCard(linked)) {
|
||||||
|
openOverlay(linked.id);
|
||||||
|
} else {
|
||||||
|
closeOverlay();
|
||||||
|
window._openDetail?.(parseInt(cardId));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (it.url) window.open(it.url, '_blank', 'noopener');
|
||||||
|
});
|
||||||
|
el.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showItemCtx(ci, ii, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
// Drag
|
||||||
|
el.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('application/x-bm', JSON.stringify({ col: ci, idx: ii }));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
el.classList.add('dragging');
|
||||||
|
});
|
||||||
|
el.addEventListener('dragend', () => el.classList.remove('dragging'));
|
||||||
|
items.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Drop target — positional: a drop indicator line appears
|
||||||
|
// between items based on the cursor's vertical position.
|
||||||
|
// The item is inserted AT that position, not always at the end.
|
||||||
|
items.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('application/x-bm')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
// Show a drop indicator between items.
|
||||||
|
clearDropIndicator(items);
|
||||||
|
const after = getDropIndex(items, e.clientY);
|
||||||
|
const children = [...items.querySelectorAll('.bm-item')];
|
||||||
|
if (after < children.length) {
|
||||||
|
children[after].classList.add('bm-drop-above');
|
||||||
|
} else if (children.length) {
|
||||||
|
children[children.length - 1].classList.add('bm-drop-below');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.addEventListener('dragleave', () => clearDropIndicator(items));
|
||||||
|
items.addEventListener('drop', (e) => {
|
||||||
|
clearDropIndicator(items);
|
||||||
|
const raw = e.dataTransfer.getData('application/x-bm');
|
||||||
|
if (!raw) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const src = JSON.parse(raw);
|
||||||
|
const destCol = ci;
|
||||||
|
if (!data) return;
|
||||||
|
const [item] = data.columns[src.col].items.splice(src.idx, 1);
|
||||||
|
if (!item) return;
|
||||||
|
// Compute insertion index from cursor position.
|
||||||
|
const insertAt = getDropIndex(items, e.clientY);
|
||||||
|
// If source and dest are the same column, the splice above
|
||||||
|
// shifted indices — getDropIndex already sees the shortened
|
||||||
|
// list, so insertAt is correct as-is.
|
||||||
|
data.columns[destCol].items.splice(insertAt, 0, item);
|
||||||
|
save();
|
||||||
|
renderColumns();
|
||||||
|
});
|
||||||
|
div.appendChild(items);
|
||||||
|
colsEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag-and-drop helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function getDropIndex(container, clientY) {
|
||||||
|
const children = [...container.querySelectorAll('.bm-item')];
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const r = children[i].getBoundingClientRect();
|
||||||
|
if (clientY < r.top + r.height / 2) return i;
|
||||||
|
}
|
||||||
|
return children.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDropIndicator(container) {
|
||||||
|
container.querySelectorAll('.bm-drop-above,.bm-drop-below').forEach(el => {
|
||||||
|
el.classList.remove('bm-drop-above', 'bm-drop-below');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wheel → horizontal scroll ────────────────────────────────────
|
||||||
|
colsEl?.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (colsEl.scrollWidth <= colsEl.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
colsEl.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// ── Slider ───────────────────────────────────────────────────────
|
||||||
|
slider?.addEventListener('input', () => {
|
||||||
|
applyColumnWidth();
|
||||||
|
renderColumns();
|
||||||
|
});
|
||||||
|
slider?.addEventListener('change', () => save());
|
||||||
|
|
||||||
|
// ── Context menus ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ctxEl = null;
|
||||||
|
|
||||||
|
function hideCtx() {
|
||||||
|
ctxEl?.remove();
|
||||||
|
ctxEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCtx(x, y, buttons) {
|
||||||
|
hideCtx();
|
||||||
|
ctxEl = document.createElement('div');
|
||||||
|
ctxEl.className = 'bm-ctx';
|
||||||
|
ctxEl.style.left = x + 'px';
|
||||||
|
ctxEl.style.top = y + 'px';
|
||||||
|
buttons.forEach(([label, icon, cls, fn]) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
if (cls) b.className = cls;
|
||||||
|
b.innerHTML = `<i class="ph ${icon} text-sm"></i>${esc(label)}`;
|
||||||
|
b.addEventListener('click', () => { hideCtx(); fn(); });
|
||||||
|
ctxEl.appendChild(b);
|
||||||
|
});
|
||||||
|
document.body.appendChild(ctxEl);
|
||||||
|
// Clamp to viewport
|
||||||
|
const r = ctxEl.getBoundingClientRect();
|
||||||
|
if (r.right > window.innerWidth - 8) ctxEl.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
|
||||||
|
if (r.bottom > window.innerHeight - 8) ctxEl.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
|
||||||
|
setTimeout(() => {
|
||||||
|
const off = (e) => { if (!ctxEl?.contains(e.target)) { hideCtx(); document.removeEventListener('mousedown', off, true); } };
|
||||||
|
document.addEventListener('mousedown', off, true);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItemCtx(col, idx, x, y) {
|
||||||
|
const item = data?.columns?.[col]?.items?.[idx];
|
||||||
|
if (!item) return;
|
||||||
|
showCtx(x, y, [
|
||||||
|
['Rename', 'ph-pencil-simple', '', async () => {
|
||||||
|
const name = await hyprcrd.dialog.show({ title: 'rename bookmark', input: true, defaultValue: item.title || '', okLabel: 'Save' });
|
||||||
|
if (name === false || name === null) return;
|
||||||
|
item.title = name;
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
['Delete', 'ph-trash', 'danger', async () => {
|
||||||
|
data.columns[col].items.splice(idx, 1);
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameColumn(col) {
|
||||||
|
const column = data?.columns?.[col];
|
||||||
|
if (!column) return;
|
||||||
|
const name = await hyprcrd.dialog.show({ title: 'rename column', input: true, defaultValue: column.name, okLabel: 'Save' });
|
||||||
|
if (name === false || name === null || name.trim() === '') return;
|
||||||
|
column.name = name.trim();
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showColCtx(col, x, y) {
|
||||||
|
const column = data?.columns?.[col];
|
||||||
|
if (!column) return;
|
||||||
|
showCtx(x, y, [
|
||||||
|
['Rename column', 'ph-pencil-simple', '', () => renameColumn(col)],
|
||||||
|
['Delete column', 'ph-trash', 'danger', async () => {
|
||||||
|
if (column.items.length) {
|
||||||
|
const ok = await hyprcrd.dialog.show({ title: 'delete column', message: `"${column.name}" has ${column.items.length} bookmark(s). Delete anyway?`, danger: true, okLabel: 'Delete' });
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
data.columns.splice(col, 1);
|
||||||
|
if (!data.columns.length) data.columns.push({ name: 'General', items: [] });
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add bookmark (clipboard + meta-fetch) ────────────────────────
|
||||||
|
|
||||||
|
async function addBookmark(colIdx) {
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
const clip = await navigator.clipboard.readText();
|
||||||
|
if (isUrl(clip) || isCardLink(clip)) url = clip.trim();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
const entered = await hyprcrd.dialog.show({
|
||||||
|
title: 'new bookmark',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'https://... or card:123',
|
||||||
|
okLabel: 'Add',
|
||||||
|
});
|
||||||
|
if (!entered || !entered.trim()) return;
|
||||||
|
url = entered.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { url, title: url, favicon: '' };
|
||||||
|
|
||||||
|
if (isCardLink(url)) {
|
||||||
|
// Resolve card title for the display label.
|
||||||
|
const cid = parseCardId(url);
|
||||||
|
const card = (window.state?.cards || []).find(c => String(c.id) === cid);
|
||||||
|
if (card) item.title = card.title || url;
|
||||||
|
} else if (isUrl(url)) {
|
||||||
|
try {
|
||||||
|
const meta = await hyprcrd.api('api/bookmarks/fetch-meta?url=' + encodeURIComponent(url));
|
||||||
|
if (meta?.ok) {
|
||||||
|
if (meta.title) item.title = meta.title;
|
||||||
|
if (meta.favicon) item.favicon = meta.favicon;
|
||||||
|
if (meta.url) item.url = meta.url;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.columns[colIdx].items.push(item);
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add column ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function addColumn() {
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'new column',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'column name',
|
||||||
|
okLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
data.columns.push({ name: name.trim(), items: [] });
|
||||||
|
await save();
|
||||||
|
renderColumns();
|
||||||
|
// Scroll to the new column
|
||||||
|
setTimeout(() => { colsEl.scrollLeft = colsEl.scrollWidth; }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header button injection ──────────────────────────────────────
|
||||||
|
// Creates a new #bookmarks card (or opens the most recent one).
|
||||||
|
|
||||||
|
function injectHeaderButton() {
|
||||||
|
if ($('bookmarks-btn')) return;
|
||||||
|
const header = document.querySelector('header#main-header');
|
||||||
|
if (!header) return;
|
||||||
|
const rows = header.querySelectorAll('.flex.items-center.gap-3');
|
||||||
|
const group = rows[rows.length - 1];
|
||||||
|
if (!group) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'bookmarks-btn';
|
||||||
|
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||||
|
btn.innerHTML = '<i class="ph ph-link text-sm"></i><span class="font-pixel text-[10px]">Bookmarks</span>';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cards = (window.state?.cards || []).filter(isBookmarkCard)
|
||||||
|
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||||
|
if (cards.length === 0) {
|
||||||
|
createBookmarkCard();
|
||||||
|
} else {
|
||||||
|
// Always show picker so the user can create new lists
|
||||||
|
// even when only one exists.
|
||||||
|
showBookmarkPicker(btn, cards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.insertBefore(btn, group.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rename the currently open bookmark card ──────────────────
|
||||||
|
|
||||||
|
async function renameCard() {
|
||||||
|
if (!activeCardId) return;
|
||||||
|
const card = (window.state?.cards || []).find(c => c.id == activeCardId);
|
||||||
|
const current = card?.title || '';
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'rename bookmark list',
|
||||||
|
input: true,
|
||||||
|
defaultValue: current,
|
||||||
|
okLabel: 'Save',
|
||||||
|
});
|
||||||
|
if (name === false || name === null || !name.trim()) return;
|
||||||
|
await hyprcrd.api(`api/cards/${activeCardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title: name.trim() },
|
||||||
|
});
|
||||||
|
$('bookmarks-title').textContent = name.trim();
|
||||||
|
await window._reloadAll?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create bookmark card with name prompt ────────────────────
|
||||||
|
|
||||||
|
async function createBookmarkCard() {
|
||||||
|
const name = await hyprcrd.dialog.show({
|
||||||
|
title: 'new bookmark list',
|
||||||
|
input: true,
|
||||||
|
placeholder: 'list name...',
|
||||||
|
okLabel: 'Create',
|
||||||
|
});
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const active = window.state?.activeBucket;
|
||||||
|
const virtualBuckets = ['all', 'none', 'current', 'favorites', 'journal'];
|
||||||
|
const bucketId = active && !virtualBuckets.includes(active) ? active : null;
|
||||||
|
const created = await hyprcrd.api('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
title: name.trim(),
|
||||||
|
body: JSON.stringify(emptyData()) + '\n\n#bookmarks',
|
||||||
|
color: '#38bdf8',
|
||||||
|
bucket_id: bucketId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (created?.id) {
|
||||||
|
await window._reloadAll?.();
|
||||||
|
openOverlay(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Picker popup (multiple bookmark cards) ───────────────────
|
||||||
|
|
||||||
|
function showBookmarkPicker(anchor, cards) {
|
||||||
|
document.querySelector('.bm-picker')?.remove();
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.className = 'bm-picker';
|
||||||
|
pop.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.bottom + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:200px;max-height:300px;display:flex;flex-direction:column;border-radius:var(--ui-radius-modal,4px);`;
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.style.cssText = 'overflow-y:auto;flex:1;';
|
||||||
|
cards.forEach(c => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;';
|
||||||
|
row.innerHTML = `<span style="width:10px;height:10px;border-radius:2px;background:${c.color || '#38bdf8'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(c.title)}</span>`;
|
||||||
|
row.addEventListener('mouseenter', () => { row.style.color = 'var(--t-bright)'; });
|
||||||
|
row.addEventListener('mouseleave', () => { row.style.color = 'var(--t-muted)'; });
|
||||||
|
row.addEventListener('click', () => { pop.remove(); openOverlay(c.id); });
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
pop.appendChild(list);
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px;background:none;border:none;border-top:1px solid var(--t-border);color:var(--t-accent);font-size:10px;font-family:var(--t-font-pixel);cursor:pointer;text-align:left;';
|
||||||
|
addBtn.innerHTML = '<i class="ph ph-plus text-xs"></i> new list';
|
||||||
|
addBtn.addEventListener('click', () => { pop.remove(); createBookmarkCard(); });
|
||||||
|
pop.appendChild(addBtn);
|
||||||
|
|
||||||
|
document.body.appendChild(pop);
|
||||||
|
// Clamp
|
||||||
|
const r = pop.getBoundingClientRect();
|
||||||
|
if (r.right > window.innerWidth - 8) pop.style.left = Math.max(8, window.innerWidth - r.width - 8) + 'px';
|
||||||
|
if (r.bottom > window.innerHeight - 8) pop.style.top = Math.max(8, window.innerHeight - r.height - 8) + 'px';
|
||||||
|
|
||||||
|
const close = (e) => { if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener('mousedown', close, true); } };
|
||||||
|
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
injectHeaderButton();
|
||||||
|
$('bookmarks-close')?.addEventListener('click', closeOverlay);
|
||||||
|
$('bookmarks-col-add')?.addEventListener('click', addColumn);
|
||||||
|
$('bookmarks-add-btn')?.addEventListener('click', () => {
|
||||||
|
if (data?.columns?.length) addBookmark(0);
|
||||||
|
});
|
||||||
|
$('bookmarks-title')?.addEventListener('click', renameCard);
|
||||||
|
overlay?.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); });
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && overlay?.classList.contains('open')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeOverlay();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
decorateBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
hyprcrd.registerPlugin?.({
|
||||||
|
name: 'bookmarks',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Column-based bookmark manager stored as cards',
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "bookmarks",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Bookmark manager stored as cards. Column-based view with favicon fetch, clipboard paste, and drag-and-drop.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["bookmarks.js"],
|
||||||
|
"css": ["bookmarks.css"],
|
||||||
|
"html": ["bookmarks.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
+393
-37
@@ -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 (
|
||||||
@@ -68,24 +108,20 @@ function chatRelaySettings(): array {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
|
/** Dual-format envelope shared with the relay sync path. Writes V2
|
||||||
|
* (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central
|
||||||
|
* helpers so the format stays in lock-step with src/bootstrap.php and
|
||||||
|
* the relay's crypto.php. */
|
||||||
function chatEncrypt(string $plain, string $relayKey): string {
|
function chatEncrypt(string $plain, string $relayKey): string {
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
return hyprcrdRelayEncrypt($plain, $relayKey);
|
||||||
$iv = random_bytes(16);
|
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatDecrypt(string $envelope, string $relayKey): string {
|
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
|
// Old behaviour returned '' for non-envelope strings; preserve that.
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
$raw = base64_decode($b64);
|
return '';
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
}
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
return hyprcrdRelayDecrypt($envelope, $relayKey);
|
||||||
$iv = substr($raw, 0, 16);
|
|
||||||
$cipher = substr($raw, 16);
|
|
||||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return $result !== false ? $result : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatGetState(string $key, $default = null) {
|
function chatGetState(string $key, $default = null) {
|
||||||
@@ -106,49 +142,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 +265,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 +311,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 +440,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 +499,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]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -3,7 +3,17 @@
|
|||||||
<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.
|
||||||
|
The caret shows only when there's somewhere to go back to
|
||||||
|
(thread / pick / room-info); on the list itself the click
|
||||||
|
is a no-op and the button styles itself as inert. -->
|
||||||
|
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
|
||||||
|
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
|
||||||
|
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
|
||||||
|
</button>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
||||||
|
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
|
||||||
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
|
<button id="chat-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-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>
|
||||||
@@ -21,11 +31,7 @@
|
|||||||
|
|
||||||
<!-- 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">
|
||||||
@@ -33,12 +39,35 @@
|
|||||||
</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
@@ -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)',
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
@@ -100,32 +110,24 @@
|
|||||||
");
|
");
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Encrypt a JSON payload with the local relay_key using the same envelope
|
/** Encrypt a JSON payload with the local relay_key using the shared
|
||||||
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
|
* dual-format envelope (GCM-V2 writes, V1 read-only fallback lives in
|
||||||
|
* hyprcrdRelayDecrypt). */
|
||||||
function collabEncryptPayload(array $fields): ?string {
|
function collabEncryptPayload(array $fields): ?string {
|
||||||
$key = collabRelaySettings()['key'] ?? '';
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
if ($key === '') return null;
|
if ($key === '') return null;
|
||||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
|
||||||
$plain = json_encode($fields);
|
$plain = json_encode($fields);
|
||||||
$iv = random_bytes(16);
|
if ($plain === false) return null;
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
return hyprcrdRelayEncrypt($plain, $key);
|
||||||
if ($cipher === false) return null;
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
||||||
function collabDecryptPayload(string $envelope): ?array {
|
function collabDecryptPayload(string $envelope): ?array {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
|
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) return null;
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
|
||||||
$raw = base64_decode($b64);
|
|
||||||
if (!$raw || strlen($raw) < 17) return null;
|
|
||||||
$key = collabRelaySettings()['key'] ?? '';
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
if ($key === '') return null;
|
if ($key === '') return null;
|
||||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
$plain = hyprcrdRelayDecrypt($envelope, $key);
|
||||||
$iv = substr($raw, 0, 16);
|
if ($plain === '') return null;
|
||||||
$cipher = substr($raw, 16);
|
|
||||||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
if ($plain === false) return null;
|
|
||||||
$data = json_decode($plain, true);
|
$data = json_decode($plain, true);
|
||||||
return is_array($data) ? $data : null;
|
return is_array($data) ? $data : null;
|
||||||
}
|
}
|
||||||
@@ -449,12 +451,13 @@ function collabSyncCards(PDO $db): array {
|
|||||||
|
|
||||||
$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) {
|
||||||
@@ -467,6 +470,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,
|
||||||
@@ -491,6 +519,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']++;
|
||||||
}
|
}
|
||||||
@@ -551,6 +580,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;
|
||||||
@@ -610,24 +666,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
|
||||||
@@ -649,6 +730,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,6 +960,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'],
|
||||||
@@ -855,6 +980,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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,6 +995,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;
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -67,6 +70,7 @@ async function pollOnce() {
|
|||||||
if (document.getElementById('modal')?.classList.contains('open')) return;
|
if (document.getElementById('modal')?.classList.contains('open')) return;
|
||||||
try {
|
try {
|
||||||
pollLocked = true;
|
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;
|
||||||
@@ -92,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();
|
||||||
@@ -156,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 ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -384,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -638,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,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?.({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Email Plugin: full-screen mail viewer overlay -->
|
<!-- Email Plugin: full-screen mail viewer overlay -->
|
||||||
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
||||||
<div class="email-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
<div class="email-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
||||||
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
||||||
<div class="gitea-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
<div class="gitea-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# oumta-dashboard plugin
|
||||||
|
|
||||||
|
Fullscreen view for the Oumta cluster dashboard (Go backend in `oumta-dashboard/`).
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- **Oumta** button in the hyprcrd top bar (next to *Tasks*)
|
||||||
|
- `window._oumtaToggle()` from the console / other plugins
|
||||||
|
|
||||||
|
Close with **ESC** or the × button.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
- **Overview** — cluster state, workload counts, attention list
|
||||||
|
- **Workloads** — filterable table (name, state, replicas, cpu/mem, image, reason)
|
||||||
|
- **Timeline** — event stream; clicking a workload filters down to its own events
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Hardwired at the top of `oumta-dashboard.js` in the `CONFIG` block:
|
||||||
|
|
||||||
|
- **baseUrl** — where the Oumta Go backend is reachable (default `http://localhost:8085`).
|
||||||
|
- **authToken** — optional, sent as `Authorization: Bearer …` when non-empty.
|
||||||
|
- **pollIntervalSec** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
||||||
|
|
||||||
|
No UI settings panel: the dashboard only runs against one specific deployment, so exposing these as editable fields would be noise.
|
||||||
|
|
||||||
|
## State today
|
||||||
|
|
||||||
|
Everything is mocked. The live indicator in the top right shows `polling` once the overlay is open, but no real HTTP call fires yet. See [`TODO.md`](./TODO.md) for the integration plan.
|
||||||
|
|
||||||
|
## Disabling
|
||||||
|
|
||||||
|
Add `"oumta-dashboard"` to the `disabled` array in `data/plugins.json`.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# oumta-dashboard — TODO
|
||||||
|
|
||||||
|
Everything in this file is work the plugin still needs to do before it's more than a mockup. Organised by phase.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- **Phase 0 (current)**: UI shell + mock data. Everything renders but no backend call is real.
|
||||||
|
- **Phase 1**: connect to real Go backend (`oumta-dashboard` repo) as endpoints become available.
|
||||||
|
- **Phase 2**: Keycloak bearer auth, PHP proxy decision, persisted settings.
|
||||||
|
- **Phase 3**: nice-to-haves (card auto-creation on incident, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — wire up the real API
|
||||||
|
|
||||||
|
All API calls live in `oumta-dashboard.js` → `api()`. Every case currently returns a `mock…` helper; swap them for `fetch` against the backend. The URL is constructed from `state.settings.baseUrl`.
|
||||||
|
|
||||||
|
### Endpoints to wire (listed in the order the UI consumes them)
|
||||||
|
|
||||||
|
| UI call | Backend endpoint (planned) | Status in backend | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api('/api/namespaces')` | `GET /api/namespaces` | **not yet implemented** | returns the namespace tabs shown at the top. See "Open backend questions" below. |
|
||||||
|
| `api('/api/cluster/overview')` | `GET /api/cluster/overview?namespace=…` | **exists (stub)** | current stub returns zeros. Also need `namespace` query param. |
|
||||||
|
| `api('/api/workloads')` | `GET /api/workloads?namespace=…&state=…&since=…` | not yet | filters: `state`, `since` (ISO), also maybe `search`. |
|
||||||
|
| `api('/api/workloads/{ns}/{name}/timeline')` | `GET /api/workloads/{ns}/{name}/timeline` | not yet | workload-detail timeline |
|
||||||
|
| `api('/api/timeline')` | `GET /api/timeline?namespace=…` | not yet | global timeline |
|
||||||
|
|
||||||
|
### Real `fetch` implementation sketch
|
||||||
|
|
||||||
|
In `api()` replace the mock dispatch with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const url = new URL(path, state.settings.baseUrl);
|
||||||
|
if (!url.searchParams.has('namespace') && state.activeNamespace)
|
||||||
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
|
const headers = state.settings.authToken
|
||||||
|
? { Authorization: 'Bearer ' + state.settings.authToken }
|
||||||
|
: {};
|
||||||
|
const res = await fetch(url, { ...opts, headers });
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
There's also a commented-out sketch inside `api()` itself — delete the mock branches once real endpoints work.
|
||||||
|
|
||||||
|
### Contract alignment — [REAL] vs [PROPOSED] fields
|
||||||
|
|
||||||
|
Mocks in `oumta-dashboard.js` are labelled inline:
|
||||||
|
|
||||||
|
- **[REAL]** — field matches the Go struct exactly (from `internal/httpapi/router.go`). Must stay byte-for-byte aligned when flipping to real.
|
||||||
|
- **[PROPOSED]** — our guess based on prose in backend `docs/README.md`. Backend author may pick different names; UI has one place per field to update, and there's a commented-out adapter slot in `api()` if several diverge.
|
||||||
|
|
||||||
|
**Overview** (`GET /api/cluster/overview`) — entirely [REAL]:
|
||||||
|
```
|
||||||
|
clusterState (string: healthy | degraded | down | unknown)
|
||||||
|
workloadsUp (int)
|
||||||
|
workloadsDown (int)
|
||||||
|
updatedAtUtc (string, RFC3339)
|
||||||
|
```
|
||||||
|
Extras shown on the Overview page (incidents count, attention list) are **derived client-side** from the workloads response — no contract addition needed from backend.
|
||||||
|
|
||||||
|
**Workloads** (`GET /api/workloads`) — [PROPOSED], derived from `docs/README.md §2`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
workloadId // synthetic handle, format "deployment/{ns}/{name}"
|
||||||
|
name // ← from doc "Name"
|
||||||
|
namespace // ← "Namespace"
|
||||||
|
state // ← "Zustand" — vocab: healthy | degraded | unavailable
|
||||||
|
replicasReady // ← "Replikas (ready/…)"
|
||||||
|
replicasDesired // ← "Replikas (…/desired)"
|
||||||
|
cpu // ← "CPU-Auslastung"
|
||||||
|
memory // ← "Speicherverbrauch"
|
||||||
|
image // ← "Aktives Docker-Image"
|
||||||
|
lastReason // ← "Letzter bekannter Grund fuer Zustandswechsel"
|
||||||
|
lastTransitionAt // ← "inkl. letzter Statuswechsel-Zeit"
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeline** (`GET /api/timeline`, `GET /api/workloads/{ns}/{name}/timeline`) — [PROPOSED], derived from `docs/README.md §3`:
|
||||||
|
```
|
||||||
|
items: [{
|
||||||
|
timestamp // RFC3339
|
||||||
|
severity // info | warning | error
|
||||||
|
workloadId // same handle as above
|
||||||
|
message // short human description
|
||||||
|
reason // cause code, e.g. CrashLoopBackOff, ImagePullBackOff
|
||||||
|
kind // deployment | incident | scale — optional UI hint, drop if absent
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Namespaces** (`GET /api/namespaces`) — [PROPOSED], new endpoint:
|
||||||
|
```
|
||||||
|
items: [{ id, label, namespace }]
|
||||||
|
```
|
||||||
|
`id` could equal `namespace` if backend treats them as the same. `label` is optional display text.
|
||||||
|
|
||||||
|
If/when real shapes differ, the fix path in priority order:
|
||||||
|
1. Rename in `mockXxx()` + renderer (one place each).
|
||||||
|
2. If multiple fields diverge on one endpoint, add a mapping step inside `api()` before `return`.
|
||||||
|
3. If the shape differs fundamentally, split the renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — auth, persistence, proxy decision
|
||||||
|
|
||||||
|
### Keycloak bearer flow
|
||||||
|
|
||||||
|
- Decision pending (see user note): probably SSO-wrapped. The plugin already sends `Authorization: Bearer <state.settings.authToken>` if a token is configured; the open question is **how the token arrives**.
|
||||||
|
- Options:
|
||||||
|
- **A.** User pastes a token into the settings tab (good for POC, not for prod).
|
||||||
|
- **B.** hyprcrd itself becomes a Keycloak client, acquires a token, exposes it via `window.hyprcrd.authToken` — plugin reads that. Requires a hyprcrd-core change.
|
||||||
|
- **C.** PHP proxy (see below) attaches the token server-side; the browser never sees it.
|
||||||
|
- **Recommendation**: go with **C** once we decide on the proxy. Until then **A** is fine for dev.
|
||||||
|
|
||||||
|
### PHP proxy in hyprcrd
|
||||||
|
|
||||||
|
- Today the plugin calls the Go backend **directly from the browser**. That means:
|
||||||
|
- The Go backend must return permissive CORS (it does: `Access-Control-Allow-Origin` is wide-open in dev).
|
||||||
|
- The user's browser must reach the backend (issue if the backend lives inside a private network).
|
||||||
|
- If we add a PHP proxy (file: `public/plugins/oumta-dashboard/backend.php`), we'd:
|
||||||
|
- Route `/api/oumta/*` → forward to `baseUrl + *`.
|
||||||
|
- Store `baseUrl` + `authToken` in hyprcrd's `settings` table instead of `localStorage`.
|
||||||
|
- Get CORS for free because the request originates from hyprcrd's own origin.
|
||||||
|
- **Trigger for adding the proxy**: when we get the Keycloak spec, or when users report "dashboard only works when I'm VPN'd".
|
||||||
|
|
||||||
|
### Settings persistence
|
||||||
|
|
||||||
|
- Currently `localStorage['oumta-dashboard-settings']` — fine for a single user in one browser.
|
||||||
|
- If we want per-tenant server-side settings, add a hyprcrd `settings` key like `oumta.base_url` and expose via a small PHP endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — realtime
|
||||||
|
|
||||||
|
- `startLive()` currently just starts polling (every `settings.pollIntervalSec`).
|
||||||
|
- SSE plan (see commented-out block in `startLive()`):
|
||||||
|
- Backend exposes `GET /api/events?namespace=…` as an `EventSource`-compatible stream.
|
||||||
|
- On `onopen` → `setLive('live')`, apply incoming events live (invalidate `state.data.overview` etc. or surgically patch).
|
||||||
|
- On `onerror` → fall back to polling, `setLive('polling')` with a hint.
|
||||||
|
- SSE event shape proposal (to negotiate with backend): `{ type: 'workload.state', workloadId, state, reason, timestamp }`, etc. Parking this until the backend is ready to talk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3+ — nice to haves (not in scope yet)
|
||||||
|
|
||||||
|
From the original questions catalog we parked:
|
||||||
|
- Timezone handling: UTC vs. local browser time (probably just use local + tooltip with UTC).
|
||||||
|
- Plugin settings in hyprcrd's settings-tab vs. per-user vs. shared across tenants.
|
||||||
|
- Auto-create a hyprcrd card when an incident is opened (tie the two systems together).
|
||||||
|
- Filter persistence in URL hash so links are shareable.
|
||||||
|
- Light/dark accent variants — currently inherits theme variables, should just work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open backend questions (to sync with the Go team)
|
||||||
|
|
||||||
|
1. **Namespace list endpoint** — UI needs an endpoint to populate the namespace tabs. The doc says "Namespace-Auswahl per Menü" and mentions env-var config on the backend. What's the endpoint? `GET /api/namespaces`? If not, how does the UI discover them?
|
||||||
|
2. **Response envelope** — the existing `overview` endpoint returns a flat object. Lists should probably be `{ items: [...] }` (consistent with our renderers) or `[]` (then we'll adapt). Pick one and stick with it across all list endpoints.
|
||||||
|
3. **Cluster state values** — our Overview card colour-codes on `healthy | degraded | down | unknown`. Backend currently returns `"unknown"` as a literal. Confirm the vocabulary.
|
||||||
|
4. **Workload state values** — we colour-code on `healthy | degraded | unavailable`. The doc uses these but the old .NET API also had `withRestarts` / `withWarnings` as summary counters. Only the three we use? Or more?
|
||||||
|
5. **Workload ID shape** — we use `deployment/{namespace}/{name}` as a synthetic id (so workloads can be linked from the timeline). Does the backend emit that exact form, or just `{namespace}/{name}`? We can adapt either way.
|
||||||
|
6. **Timeline pagination** — limit? cursor? since? The .NET version had all three; the Go doc mentions only filters. Enough for MVP?
|
||||||
|
7. **Realtime channel** — path + event format. We've got a plan above; needs the backend team's blessing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal follow-ups
|
||||||
|
|
||||||
|
- `renderWorkloads` is called on every keystroke in the filter — fine up to ~1k rows, might need debouncing above that.
|
||||||
|
- `refresh()` tears down the scoped-workload timeline whenever it fires. For Phase 1 that's acceptable; once real data lands, consider NOT overwriting `state.data.timeline` when `state.data.scopeWorkload` is active.
|
||||||
|
- `bootstrap()` only runs on first show. If the user changes the API base URL in settings while the overlay is open, we re-start live mode but don't re-fetch namespaces. Add a "reset & reload" action if that matters.
|
||||||
|
- Namespace tab bar shows `namespace.label`; we currently fake it. Once real, consider showing the full k8s namespace if `label` and `namespace` differ.
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/* ── Namespace tabs ── */
|
||||||
|
.oumta-ns-tab {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.oumta-ns-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-ns-tab.active { color: var(--t-accent); border-bottom-color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* ── View tabs ── */
|
||||||
|
.oumta-view-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-view-tab:hover { color: var(--t-bright); }
|
||||||
|
.oumta-view-tab.active { color: var(--t-accent); border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.oumta-view.hidden { display: none; }
|
||||||
|
.oumta-view { padding: 20px 24px; }
|
||||||
|
|
||||||
|
/* ── Overview cards ── */
|
||||||
|
.oumta-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.oumta-card {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.oumta-card-wide { grid-column: span 4; }
|
||||||
|
.oumta-card-label {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-card-value {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 56px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.oumta-card-value-sm { font-size: 18px; font-weight: normal; line-height: 1.3; }
|
||||||
|
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-danger { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Fail state: whole card goes red; label, value, sub all flip to white
|
||||||
|
* so the box reads as a single alarm tile rather than a coloured number
|
||||||
|
* on dark chrome. Triggered from JS via the oumta-fail class. Uses the
|
||||||
|
* theme's --t-danger to stay consistent with the "unavailable" state
|
||||||
|
* labels and workload state colours elsewhere in the dashboard. */
|
||||||
|
.oumta-card.oumta-fail {
|
||||||
|
background: var(--t-danger);
|
||||||
|
border-color: var(--t-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.oumta-card.oumta-fail .oumta-card-label,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-value,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-sub {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
|
||||||
|
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
|
||||||
|
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
|
||||||
|
/* data-state="down" is handled by .oumta-fail (toggled from JS) so the
|
||||||
|
* cluster tile goes fully red + white like the other alarms. */
|
||||||
|
|
||||||
|
/* ── Attention list ── */
|
||||||
|
.oumta-section { margin-top: 8px; }
|
||||||
|
.oumta-section-title {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-attention-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.oumta-attention-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-attention-row:hover { border-color: var(--t-accent); }
|
||||||
|
.oumta-attention-name { flex: 1; color: var(--t-bright); font-size: 13px; }
|
||||||
|
.oumta-attention-ns { color: var(--t-dim); font-size: 11px; font-family: var(--font-content, monospace); }
|
||||||
|
.oumta-attention-reason { color: var(--t-muted); font-size: 11px; font-style: italic; }
|
||||||
|
.oumta-attention-state {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.oumta-attention-state[data-state="degraded"] { color: #fb923c; border-color: #fb923c; }
|
||||||
|
.oumta-attention-state[data-state="unavailable"] { color: var(--t-danger); border-color: var(--t-danger); }
|
||||||
|
|
||||||
|
.oumta-empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Workload toolbar + table ── */
|
||||||
|
.oumta-toolbar {
|
||||||
|
display: flex; gap: 10px; margin-bottom: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.oumta-input {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.oumta-input:focus { border-color: var(--t-accent); }
|
||||||
|
#oumta-wl-search { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
.oumta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.oumta-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.oumta-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
||||||
|
.oumta-table tbody tr:hover { background: var(--t-bg); }
|
||||||
|
.oumta-table .wl-state-healthy { color: #4ade80; }
|
||||||
|
.oumta-table .wl-state-degraded { color: #fb923c; }
|
||||||
|
.oumta-table .wl-state-unavailable{ color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* ── Timeline ── */
|
||||||
|
.oumta-timeline-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Workload filter pills: inline quick-pick. Horizontal-scrolls if the row
|
||||||
|
* runs out of space; an overflow dropdown is injected from JS when the
|
||||||
|
* pill count would blow out past the container. */
|
||||||
|
.oumta-tl-filters {
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.oumta-tl-pill {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.oumta-tl-pill:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.oumta-tl-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
|
||||||
|
|
||||||
|
.oumta-tl-more {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px); left: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button:hover { background: var(--t-bg); color: var(--t-bright); }
|
||||||
|
.oumta-tl-more-menu button.active { color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Timeline body: two-column layout (left = date badge + time + dot, right
|
||||||
|
* = event list). Clusters of events that fired within the same tolerance
|
||||||
|
* window get a single row with all sub-events listed in the right column.
|
||||||
|
*
|
||||||
|
* The decorative connector line is drawn ONCE on the body, not per entry —
|
||||||
|
* that's the only way to guarantee it's truly continuous (per-entry
|
||||||
|
* pseudo-elements collapse into gaps the moment an entry has a shorter
|
||||||
|
* left column than right). */
|
||||||
|
.oumta-timeline-body {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 79px; /* right edge of the 80px left column */
|
||||||
|
top: 24px; /* begin a bit below the first dot */
|
||||||
|
bottom: 24px; /* end a bit above the last dot */
|
||||||
|
width: 2px;
|
||||||
|
/* Soft fade at both ends so the line feels like it eases into the
|
||||||
|
* timeline rather than being clipped. The middle stays fully opaque. */
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
var(--t-border) 28px,
|
||||||
|
var(--t-border) calc(100% - 28px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
grid-template-rows: auto auto; /* optional date row on top, dot-row + events below */
|
||||||
|
gap: 0 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry > * { position: relative; z-index: 1; }
|
||||||
|
/* Time + dot share a horizontal row so clusters without a date badge
|
||||||
|
* show the time label LEFT of the dot, not stacked underneath. The row
|
||||||
|
* lives in grid row 2 so events in the right column (also row 2) stay
|
||||||
|
* vertically aligned with it — regardless of whether a date badge
|
||||||
|
* occupies row 1 above. */
|
||||||
|
.oumta-tl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: end; /* hug the connector line on the right */
|
||||||
|
align-self: start; /* don't stretch to the events column's full height — stay at the top */
|
||||||
|
}
|
||||||
|
.oumta-tl-date {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 4px 0 5px;
|
||||||
|
width: 44px;
|
||||||
|
background: var(--t-bg); /* solid, so the connector line behind doesn't strike through the text */
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: start; /* badge hugs the left edge of the column */
|
||||||
|
margin-bottom: 8px; /* spacing before the dot-row below */
|
||||||
|
}
|
||||||
|
.oumta-tl-date .day {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 18px; font-weight: 700;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.oumta-tl-date .mon {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.oumta-tl-time {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.oumta-tl-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* Dot sits at the right end of the time+dot row, which align-items:
|
||||||
|
* flex-end of the left column pushes to the right edge (x=80).
|
||||||
|
* Translate by half the dot width so its centre lines up exactly with
|
||||||
|
* the connector line (also at x=80). */
|
||||||
|
transform: translateX(5px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
|
||||||
|
background: var(--oumta-dot-color);
|
||||||
|
}
|
||||||
|
.oumta-tl-dot[data-sev="info"] { --oumta-dot-color: var(--t-accent); }
|
||||||
|
.oumta-tl-dot[data-sev="warning"] { --oumta-dot-color: #fb923c; }
|
||||||
|
.oumta-tl-dot[data-sev="error"] { --oumta-dot-color: var(--t-danger); }
|
||||||
|
.oumta-tl-dot[data-sev="recovered"] { --oumta-dot-color: #4ade80; }
|
||||||
|
.oumta-tl-dot[data-sev="image"] { --oumta-dot-color: #60a5fa; }
|
||||||
|
|
||||||
|
.oumta-tl-right {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 23px;
|
||||||
|
min-width: 0;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2; /* pinned to the same row as .oumta-tl-row so events line up with the dot */
|
||||||
|
}
|
||||||
|
.oumta-tl-event {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.oumta-tl-event-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity[data-sev="info"] { color: var(--t-accent); }
|
||||||
|
.oumta-tl-severity[data-sev="warning"] { color: #fb923c; }
|
||||||
|
.oumta-tl-severity[data-sev="error"] { color: var(--t-danger); }
|
||||||
|
.oumta-tl-severity[data-sev="recovered"] { color: #4ade80; }
|
||||||
|
.oumta-tl-severity[data-sev="image"] { color: #60a5fa; }
|
||||||
|
.oumta-tl-workload {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
.oumta-tl-workload:hover { color: var(--t-accent); }
|
||||||
|
.oumta-tl-reason { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-tl-message { font-size: 12px; color: var(--t-bright); line-height: 1.4; word-break: break-word; }
|
||||||
|
|
||||||
|
/* ── Live indicator ── */
|
||||||
|
.oumta-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--t-dim);
|
||||||
|
display: inline-block;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
|
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
|
||||||
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
|
|
||||||
|
<!-- 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" 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>
|
||||||
|
<!-- Workload filter: quick-pick pills by default, a dropdown
|
||||||
|
trigger appears only when the pills don't fit. -->
|
||||||
|
<div id="oumta-timeline-filters" class="oumta-tl-filters"></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,591 @@
|
|||||||
|
// ── Oumta Dashboard plugin ──
|
||||||
|
//
|
||||||
|
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
||||||
|
// The browser talks to /api/dashboard/* same-origin; the hyprcrd PHP
|
||||||
|
// backend forwards to the real upstream (see OUMTA_DASHBOARD_URL in
|
||||||
|
// config.php). Keeps CORS out of the browser and lets the dashboard
|
||||||
|
// live on a private network reachable only by the hyprcrd container.
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
proxyPrefix: '/api/dashboard',
|
||||||
|
pollIntervalSec: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
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 },
|
||||||
|
timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Central API surface ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every backend call routes through `api()` against the same-origin
|
||||||
|
// proxy prefix; the active namespace (if any) is attached as a
|
||||||
|
// `namespace` query parameter. Auth / bearer tokens live server-side
|
||||||
|
// in config.php, so nothing sensitive ships to the browser. Response
|
||||||
|
// shapes are defined by the Go backend in
|
||||||
|
// `internal/httpapi/contracts.go`.
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const url = new URL(CONFIG.proxyPrefix + path, window.location.origin);
|
||||||
|
if (!url.searchParams.has('namespace') && state.activeNamespace) {
|
||||||
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fullscreen overlay show/hide ─────────────────────────────────
|
||||||
|
|
||||||
|
const overlay = $('oumta-overlay');
|
||||||
|
|
||||||
|
function isOpen() {
|
||||||
|
return overlay?.classList.contains('open');
|
||||||
|
}
|
||||||
|
function show() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.add('open');
|
||||||
|
// Always kick off polling — refresh() self-heals: it loads namespaces
|
||||||
|
// if they aren't there yet, and the interval keeps retrying even if
|
||||||
|
// the initial poll fails. Previously an initial bootstrap failure
|
||||||
|
// stopped the loop entirely and required a page reload to recover.
|
||||||
|
refresh();
|
||||||
|
startLive();
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
stopLive();
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
isOpen() ? hide() : show();
|
||||||
|
}
|
||||||
|
window._oumtaToggle = toggle;
|
||||||
|
|
||||||
|
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 ──────────────────
|
||||||
|
//
|
||||||
|
// Self-healing: if namespaces aren't loaded yet (e.g. initial poll
|
||||||
|
// failed), fetch them first; if upstream is down, leave state as
|
||||||
|
// 'error' and let the next interval tick try again. On any successful
|
||||||
|
// poll we flip back to 'polling' so the indicator recovers without a
|
||||||
|
// page reload.
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
if (!state.activeNamespace) {
|
||||||
|
const res = await api('/api/namespaces');
|
||||||
|
state.namespaces = res.items || [];
|
||||||
|
state.activeNamespace = state.namespaces[0] || null;
|
||||||
|
renderNamespaceTabs();
|
||||||
|
}
|
||||||
|
if (!state.activeNamespace) {
|
||||||
|
// Namespaces endpoint returned empty — nothing to poll yet.
|
||||||
|
// Stay in 'polling' so we keep retrying on the interval.
|
||||||
|
setLive('polling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
setLive('polling');
|
||||||
|
} 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].
|
||||||
|
const clusterCard = $('oumta-cluster-card');
|
||||||
|
const clusterState = o.clusterState || 'unknown';
|
||||||
|
clusterCard.dataset.state = clusterState;
|
||||||
|
clusterCard.classList.toggle('oumta-fail', clusterState === 'down');
|
||||||
|
$('oumta-cluster-state').textContent = clusterState;
|
||||||
|
$('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) : '—';
|
||||||
|
|
||||||
|
// Any workload down → the "workloads down" tile becomes an alarm tile.
|
||||||
|
const downCard = $('oumta-workloads-down').closest('.oumta-card');
|
||||||
|
if (downCard) downCard.classList.toggle('oumta-fail', (o.workloadsDown ?? 0) > 0);
|
||||||
|
|
||||||
|
// 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 incCard = $('oumta-incidents').closest('.oumta-card');
|
||||||
|
if (incCard) incCard.classList.toggle('oumta-fail', attention.length > 0);
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeline helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const TL_CLUSTER_WINDOW_MS = 60_000; // events within 60s collapse into one entry
|
||||||
|
const TL_SEV_RANK = { error: 5, warning: 4, recovered: 3, image: 2, info: 1 };
|
||||||
|
const TL_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
||||||
|
|
||||||
|
/** Normalise an event's severity. Two post-hoc classifications matter:
|
||||||
|
* "-> healthy" transitions are recoveries, and ImageRolled reasons are
|
||||||
|
* real image pushes (vs. plain revision bumps which are just info). */
|
||||||
|
function eventSev(e) {
|
||||||
|
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
|
||||||
|
const isImageRoll = e.reason === 'ImageRolled';
|
||||||
|
if (isRecovery) return 'recovered';
|
||||||
|
if (isImageRoll) return 'image';
|
||||||
|
return e.severity || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workload short-name for the filter pill (strip namespace prefix). */
|
||||||
|
function workloadShort(id) {
|
||||||
|
if (!id) return '?';
|
||||||
|
const parts = id.split('/');
|
||||||
|
return parts[parts.length - 1] || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy clustering: walk events (assumed newest-first) and start a new
|
||||||
|
* cluster whenever the gap exceeds the tolerance window. */
|
||||||
|
function clusterEvents(events) {
|
||||||
|
const clusters = [];
|
||||||
|
let current = null;
|
||||||
|
for (const e of events) {
|
||||||
|
const ts = Date.parse(e.timestamp);
|
||||||
|
if (!current || isNaN(ts) || Math.abs(current.anchorTs - ts) > TL_CLUSTER_WINDOW_MS) {
|
||||||
|
current = { anchorTs: ts, ts: e.timestamp, events: [e] };
|
||||||
|
clusters.push(current);
|
||||||
|
} else {
|
||||||
|
current.events.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantSev(events) {
|
||||||
|
let best = null;
|
||||||
|
for (const e of events) {
|
||||||
|
const sev = eventSev(e);
|
||||||
|
if (!best || TL_SEV_RANK[sev] > TL_SEV_RANK[best]) best = sev;
|
||||||
|
}
|
||||||
|
return best || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan the cluster (events are newest-first) for the most recent state
|
||||||
|
* transition and map it to a dot severity. This reflects how the
|
||||||
|
* workload ended up after the cluster, not the worst thing that
|
||||||
|
* happened inside it — so a cluster that ends in "-> healthy" shows a
|
||||||
|
* green dot even if it contains degraded/warning events along the way.
|
||||||
|
* A cluster without any state transition (rollouts or image changes
|
||||||
|
* that didn't flip state) counts as "uneventful" → also green, since
|
||||||
|
* the absence of a transition means the workload stayed in whatever
|
||||||
|
* state it was in. */
|
||||||
|
function clusterOutcomeSev(events) {
|
||||||
|
for (const e of events) {
|
||||||
|
const m = /->\s*(\w+)\s*$/.exec(e.message || '');
|
||||||
|
if (!m) continue;
|
||||||
|
switch (m[1]) {
|
||||||
|
case 'healthy': return 'recovered';
|
||||||
|
case 'degraded': return 'warning';
|
||||||
|
case 'unavailable': return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'recovered';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a, b) {
|
||||||
|
const da = new Date(a), db = new Date(b);
|
||||||
|
return da.getFullYear() === db.getFullYear()
|
||||||
|
&& da.getMonth() === db.getMonth()
|
||||||
|
&& da.getDate() === db.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeShort(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const now = new Date();
|
||||||
|
if (Math.abs(now - d) < 60_000) return 'just now';
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const n = state.timelineFilter.size;
|
||||||
|
scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`;
|
||||||
|
backEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
const filter = state.timelineFilter;
|
||||||
|
const events = state.data.timeline.filter(e => {
|
||||||
|
if (scope) return e.workloadId === scope;
|
||||||
|
if (filter.size === 0) return true;
|
||||||
|
return filter.has(e.workloadId);
|
||||||
|
});
|
||||||
|
renderTimelineFilters();
|
||||||
|
|
||||||
|
const body = $('oumta-timeline-body');
|
||||||
|
if (!events.length) {
|
||||||
|
body.innerHTML = '<div class="oumta-empty">No events.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clusters = clusterEvents(events);
|
||||||
|
|
||||||
|
let lastDate = null;
|
||||||
|
body.innerHTML = clusters.map(cluster => {
|
||||||
|
const sev = clusterOutcomeSev(cluster.events) || dominantSev(cluster.events);
|
||||||
|
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
|
||||||
|
lastDate = cluster.ts;
|
||||||
|
const d = new Date(cluster.ts);
|
||||||
|
const badge = showDate
|
||||||
|
? `<div class="oumta-tl-date" title="${escapeAttr(d.toDateString())}">
|
||||||
|
<span class="day">${d.getDate()}</span>
|
||||||
|
<span class="mon">${TL_MONTHS[d.getMonth()]}</span>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
const left = `
|
||||||
|
${badge}
|
||||||
|
<div class="oumta-tl-row">
|
||||||
|
<span class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</span>
|
||||||
|
<span class="oumta-tl-dot" data-sev="${sev}"></span>
|
||||||
|
</div>`;
|
||||||
|
const right = `
|
||||||
|
<div class="oumta-tl-right">
|
||||||
|
${cluster.events.map(e => {
|
||||||
|
const esev = eventSev(e);
|
||||||
|
const wid = e.workloadId || '';
|
||||||
|
return `
|
||||||
|
<div class="oumta-tl-event">
|
||||||
|
<div class="oumta-tl-event-head">
|
||||||
|
<span class="oumta-tl-severity" data-sev="${esev}">${esev}</span>
|
||||||
|
<span class="oumta-tl-workload" data-wlid="${escapeAttr(wid)}" title="filter by this workload">${escapeHtml(workloadShort(wid))}</span>
|
||||||
|
<span class="oumta-tl-reason">${escapeHtml(e.reason || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-tl-message">${escapeHtml(e.message || '')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Tapping a workload name inside an event toggles it in the filter set.
|
||||||
|
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
|
||||||
|
el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle a workload in the multi-select filter set. Passing an empty
|
||||||
|
* value clears the set (the "all" pill). */
|
||||||
|
function toggleTimelineFilter(workloadId) {
|
||||||
|
const set = state.timelineFilter;
|
||||||
|
if (!workloadId) { set.clear(); }
|
||||||
|
else if (set.has(workloadId)) { set.delete(workloadId); }
|
||||||
|
else { set.add(workloadId); }
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the workload quick-pick bar. "all" + distinct workloads seen in
|
||||||
|
* the current timeline, capped at N inline pills — overflow collapses
|
||||||
|
* into a dropdown. Deduplicated by workloadId, ranked by most recent
|
||||||
|
* event so the pills you care about stay visible. */
|
||||||
|
function renderTimelineFilters() {
|
||||||
|
const host = $('oumta-timeline-filters');
|
||||||
|
if (!host) return;
|
||||||
|
// In workload-detail scope (opened via a card click) the filter bar is
|
||||||
|
// redundant — we're already showing one workload's events.
|
||||||
|
if (state.data.scopeWorkload) { host.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const seen = new Map(); // workloadId -> earliest-seen index (= recency)
|
||||||
|
state.data.timeline.forEach((e, i) => {
|
||||||
|
if (e.workloadId && !seen.has(e.workloadId)) seen.set(e.workloadId, i);
|
||||||
|
});
|
||||||
|
const workloads = [...seen.keys()];
|
||||||
|
if (!workloads.length) { host.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const INLINE_LIMIT = 6;
|
||||||
|
const inline = workloads.slice(0, INLINE_LIMIT);
|
||||||
|
const overflow = workloads.slice(INLINE_LIMIT);
|
||||||
|
const active = state.timelineFilter; // Set<workloadId>
|
||||||
|
|
||||||
|
const pill = (id, label, isActive) =>
|
||||||
|
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
|
||||||
|
|
||||||
|
// "all" is active whenever the filter set is empty; picking it clears
|
||||||
|
// the set. The individual pills toggle their workload in the set so
|
||||||
|
// multiple filters can stack (any-of match).
|
||||||
|
const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0);
|
||||||
|
host.innerHTML = [
|
||||||
|
pill('', 'all', active.size === 0),
|
||||||
|
...inline.map(id => pill(id, workloadShort(id), active.has(id))),
|
||||||
|
overflow.length ? `
|
||||||
|
<div class="oumta-tl-more">
|
||||||
|
<button class="oumta-tl-pill${overflowActiveCount ? ' active' : ''}" data-tl-more="1">+${overflow.length} more${overflowActiveCount ? ` (${overflowActiveCount})` : ''}<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
|
||||||
|
<div class="oumta-tl-more-menu hidden">
|
||||||
|
${overflow.map(id => `<button class="${active.has(id) ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || ''));
|
||||||
|
});
|
||||||
|
const moreBtn = host.querySelector('[data-tl-more]');
|
||||||
|
const moreMenu = host.querySelector('.oumta-tl-more-menu');
|
||||||
|
if (moreBtn && moreMenu) {
|
||||||
|
moreBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
moreMenu.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
// Overflow picks don't close the menu — multi-select UX relies on
|
||||||
|
// being able to tick several without the menu collapsing every time.
|
||||||
|
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleTimelineFilter(btn.dataset.wlid || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!moreMenu.contains(e.target) && e.target !== moreBtn) moreMenu.classList.add('hidden');
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Polling loop ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// SSE intentionally unsupported: the PHP proxy buffers whole responses,
|
||||||
|
// so streaming through it would pin an Apache worker per client. If we
|
||||||
|
// ever want live events, bypass the proxy and talk to the Go backend's
|
||||||
|
// WebSocket / SSE endpoint directly — browsers don't enforce CORS on
|
||||||
|
// WS, and SSE would need a short-lived signed URL from the proxy.
|
||||||
|
|
||||||
|
function startLive() {
|
||||||
|
stopLive();
|
||||||
|
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 = (CONFIG.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
function escapeAttr(s) { return escapeHtml(s); }
|
||||||
|
function formatTime(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
} catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?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', 'DELETE'], 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') ?: '{}';
|
||||||
|
} elseif ($method === 'DELETE') {
|
||||||
|
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
|
||||||
|
$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;
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
// ── Shopping-list plugin (hyprcrd side) ──
|
||||||
|
//
|
||||||
|
// Tag a card with #einkauf or #shopping to make it eligible. Pick
|
||||||
|
// any number as "active" via the Shopping header button; each 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 cards.
|
||||||
|
//
|
||||||
|
// 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-ids';
|
||||||
|
const SYNCED_KEY = 'hyprcrd-shopping-synced';
|
||||||
|
const ORDER_KEY = 'hyprcrd-shopping-order';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEinkaufTag(card) {
|
||||||
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||||
|
return tags.includes('einkauf') || tags.includes('shopping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active IDs (multi-select) ────────────────────────────────
|
||||||
|
|
||||||
|
function getActiveIds() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
function setActiveIds(ids) {
|
||||||
|
localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String)));
|
||||||
|
}
|
||||||
|
function isActive(id) {
|
||||||
|
return getActiveIds().includes(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveCards() {
|
||||||
|
const ids = getActiveIds();
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const cards = window.state?.cards || [];
|
||||||
|
return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredOrder() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; }
|
||||||
|
}
|
||||||
|
function setStoredOrder(ids) {
|
||||||
|
localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String)));
|
||||||
|
}
|
||||||
|
function sortByOrder(cards) {
|
||||||
|
const order = getStoredOrder();
|
||||||
|
const idx = new Map(order.map((id, i) => [id, i]));
|
||||||
|
return [...cards].sort((a, b) => {
|
||||||
|
const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999;
|
||||||
|
const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Synced state (per card_uid) ──────────────────────────────
|
||||||
|
|
||||||
|
function getSyncedMap() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function setSyncedFor(uid, title, body, pos) {
|
||||||
|
const m = getSyncedMap();
|
||||||
|
m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 };
|
||||||
|
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||||
|
}
|
||||||
|
function removeSyncedFor(uid) {
|
||||||
|
const m = getSyncedMap();
|
||||||
|
delete m[String(uid)];
|
||||||
|
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header button injection ──────────────────────────────────
|
||||||
|
|
||||||
|
function injectHeaderButton() {
|
||||||
|
if ($('shopping-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 = '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);
|
||||||
|
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 lists</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 activated for the phone.
|
||||||
|
Check any number of lists; their items appear stacked 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 = sortByOrder(cards.filter(hasEinkaufTag));
|
||||||
|
const currentIds = new Set(getActiveIds());
|
||||||
|
const selected = new Set(currentIds);
|
||||||
|
|
||||||
|
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 = selected.has(id) ? '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 `<div class="shopping-picker-row flex items-start gap-3 px-3 py-2 hover:bg-t-raised" draggable="true" data-card-id="${id}">
|
||||||
|
<i class="ph ph-dots-six-vertical text-t-dim" style="margin-top:4px;cursor:grab;flex-shrink:0;"></i>
|
||||||
|
<label class="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||||
|
<input type="checkbox" data-card-id="${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>
|
||||||
|
<button class="shopping-goto text-t-dim hover:text-t-accent transition-colors" data-card-id="${id}" data-bucket="${c.bucket_id || ''}" title="go to card" style="padding:4px;margin-top:1px;"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
if (cb.checked) selected.add(cb.dataset.cardId);
|
||||||
|
else selected.delete(cb.dataset.cardId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Drag-and-drop reordering of picker rows.
|
||||||
|
let dragRow = null;
|
||||||
|
list.querySelectorAll('.shopping-picker-row').forEach(row => {
|
||||||
|
row.addEventListener('dragstart', (e) => {
|
||||||
|
dragRow = row;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
row.style.opacity = '0.4';
|
||||||
|
});
|
||||||
|
row.addEventListener('dragend', () => {
|
||||||
|
row.style.opacity = '';
|
||||||
|
dragRow = null;
|
||||||
|
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||||
|
});
|
||||||
|
row.addEventListener('dragover', (e) => {
|
||||||
|
if (!dragRow || dragRow === row) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const mid = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < mid) {
|
||||||
|
row.style.borderTop = '2px solid var(--t-accent)';
|
||||||
|
} else {
|
||||||
|
const next = row.nextElementSibling;
|
||||||
|
if (next) next.style.borderTop = '2px solid var(--t-accent)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragRow || dragRow === row) return;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const mid = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < mid) {
|
||||||
|
list.insertBefore(dragRow, row);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(dragRow, row.nextSibling);
|
||||||
|
}
|
||||||
|
// Persist new order from DOM.
|
||||||
|
const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId);
|
||||||
|
setStoredOrder(newOrder);
|
||||||
|
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('.shopping-goto').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const cardId = btn.dataset.cardId;
|
||||||
|
const bucketId = btn.dataset.bucket;
|
||||||
|
close();
|
||||||
|
// Switch bucket if needed so the card is visible.
|
||||||
|
if (bucketId && window.state?.activeBucket !== bucketId
|
||||||
|
&& window.state?.activeBucket !== 'all') {
|
||||||
|
window.state.activeBucket = bucketId;
|
||||||
|
localStorage.setItem('hyprcrd-active-bucket', bucketId);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
setTimeout(() => window._openDetail?.(parseInt(cardId)), 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
|
||||||
|
const prev = getActiveIds();
|
||||||
|
const next = [...selected];
|
||||||
|
setActiveIds(next);
|
||||||
|
status.innerHTML = '<i class="ph ph-spinner card-spinner" style="font-size:14px;vertical-align:middle;margin-right:4px;"></i> saving — please wait!';
|
||||||
|
try {
|
||||||
|
// Remove deactivated lists from relay.
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!selected.has(id)) {
|
||||||
|
removeSyncedFor(id);
|
||||||
|
await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Push all active lists.
|
||||||
|
await tick();
|
||||||
|
status.textContent = 'saved';
|
||||||
|
setTimeout(close, 400);
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'error: ' + (e?.message || e);
|
||||||
|
status.style.color = 'var(--t-danger)';
|
||||||
|
setActiveIds(prev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pushOne(card) {
|
||||||
|
const uid = String(card.id);
|
||||||
|
const title = card.title || '';
|
||||||
|
const body = card.body || '';
|
||||||
|
const order = getStoredOrder();
|
||||||
|
const pos = order.indexOf(uid);
|
||||||
|
const synced = getSyncedMap()[uid];
|
||||||
|
if (synced && synced.title === title && synced.body === body && synced.pos === pos) return;
|
||||||
|
const res = await hyprcrd.api('api/shopping', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||||
|
});
|
||||||
|
if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullOne(card, relayState) {
|
||||||
|
const uid = String(card.id);
|
||||||
|
const { body, title } = relayState;
|
||||||
|
const synced = getSyncedMap()[uid];
|
||||||
|
// Preserve the position we last pushed so a pull doesn't reset it
|
||||||
|
// and cause an unnecessary re-push on the next tick.
|
||||||
|
const curPos = synced?.pos ?? -1;
|
||||||
|
if (synced && synced.body === (body || '') && synced.title === (title || '')) return;
|
||||||
|
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
|
||||||
|
setSyncedFor(card.id, card.title || '', card.body || '', curPos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||||
|
setSyncedFor(card.id, card.title || '', body || '', curPos);
|
||||||
|
await window._reloadAll?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const activeCards = getActiveCards();
|
||||||
|
const activeUids = activeCards.map(c => String(c.id));
|
||||||
|
// Push all active cards first.
|
||||||
|
for (const card of activeCards) {
|
||||||
|
try { await pushOne(card); } catch {}
|
||||||
|
}
|
||||||
|
// Tell the relay to drop any rows that aren't in our active set.
|
||||||
|
// This cleans up stale entries from previous activations that
|
||||||
|
// were never explicitly deleted (old single-list era, failed
|
||||||
|
// DELETEs, migration leftovers).
|
||||||
|
try {
|
||||||
|
await hyprcrd.api('api/shopping', { method: 'DELETE', body: { keep: activeUids } });
|
||||||
|
} catch {}
|
||||||
|
// Pull: fetch all lists from relay, match by card_uid.
|
||||||
|
try {
|
||||||
|
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||||
|
if (!res?.ok || !Array.isArray(res.lists)) return;
|
||||||
|
const byUid = new Map(res.lists.map(l => [l.card_uid, l]));
|
||||||
|
for (const card of activeCards) {
|
||||||
|
const relay = byUid.get(String(card.id));
|
||||||
|
if (relay) await pullOne(card, relay);
|
||||||
|
}
|
||||||
|
} 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.1.0',
|
||||||
|
description: 'Sync shopping cards to the relay so they\'re checkable from a phone.',
|
||||||
|
});
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support Plugin: backend (hyprcrd side).
|
||||||
|
*
|
||||||
|
* Talks to a standalone support-relay via HTTP. Tickets live on the
|
||||||
|
* relay; this plugin keeps a local cache + links each ticket to a
|
||||||
|
* hyprcrd card so the user can answer from the card detail view.
|
||||||
|
* KB fragments stay purely in hyprcrd (cards in a configured bucket).
|
||||||
|
*
|
||||||
|
* Routes (auth-protected, registered via the plugin dispatch):
|
||||||
|
* GET /api/support/tickets list from local cache
|
||||||
|
* GET /api/support/tickets/{id} single ticket w/ replies
|
||||||
|
* POST /api/support/tickets/{id}/reply forward reply to the relay
|
||||||
|
* PATCH /api/support/tickets/{id}/status forward status change
|
||||||
|
* POST /api/support/poll pull tickets from the relay
|
||||||
|
* GET /api/support/kb?q=... search KB fragments (local cards)
|
||||||
|
* POST /api/support/kb save a new KB fragment
|
||||||
|
* GET /api/support/settings read plugin config (no secret)
|
||||||
|
* POST /api/support/settings write plugin config
|
||||||
|
* POST /api/support/settings/test ping the configured relay
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Schema ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
// Local cache of tickets that live on the relay. We map relay_id
|
||||||
|
// (authoritative) to hyprcrd card_id so the card detail view can
|
||||||
|
// surface the ticket + its reply history.
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||||
|
relay_id INTEGER PRIMARY KEY,
|
||||||
|
card_id INTEGER,
|
||||||
|
issue_id TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL DEFAULT "",
|
||||||
|
status TEXT NOT NULL DEFAULT "open",
|
||||||
|
message_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_reply_at TEXT,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_card ON support_tickets(card_id)');
|
||||||
|
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS support_replies (
|
||||||
|
relay_id INTEGER PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES support_tickets(relay_id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_replies_ticket ON support_replies(ticket_id, created_at)');
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Encryption (API key at rest) ──────────────────────────────────
|
||||||
|
|
||||||
|
function supportEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
function supportEncrypt(string $plain): string {
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
function supportDecrypt(string $encoded): string {
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportSetting(string $key, ?string $default = null): ?string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT value FROM settings WHERE key = :k');
|
||||||
|
$stmt->execute(['k' => 'support_' . $key]);
|
||||||
|
$v = $stmt->fetchColumn();
|
||||||
|
return $v === false ? $default : $v;
|
||||||
|
}
|
||||||
|
function supportSetSetting(string $key, ?string $value): void {
|
||||||
|
$db = Database::connect();
|
||||||
|
if ($value === null) {
|
||||||
|
$db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => 'support_' . $key]);
|
||||||
|
} else {
|
||||||
|
$db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)')
|
||||||
|
->execute(['k' => 'support_' . $key, 'v' => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportRelayUrl(): string {
|
||||||
|
return rtrim((string)supportSetting('relay_url', ''), '/');
|
||||||
|
}
|
||||||
|
function supportRelayKey(): string {
|
||||||
|
$enc = supportSetting('relay_key_enc', '');
|
||||||
|
return $enc ? supportDecrypt($enc) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cURL helper for the support-relay. Short timeouts — this runs inline
|
||||||
|
* from user-triggered flows. Responses are returned as the decoded body
|
||||||
|
* plus the HTTP status so callers can surface errors. */
|
||||||
|
function supportCurl(string $path, string $method = 'GET', ?array $body = null): array {
|
||||||
|
$url = supportRelayUrl() . $path;
|
||||||
|
$key = supportRelayKey();
|
||||||
|
if ($url === '' || $key === '') return ['status' => 0, 'error' => 'support relay not configured', 'body' => null];
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = ['X-Support-Key: ' . $key, 'Accept: application/json'];
|
||||||
|
$opts = [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
];
|
||||||
|
if ($body !== null) {
|
||||||
|
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
|
||||||
|
$opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, $opts);
|
||||||
|
if (function_exists('hyprcrdApplySafeCurlOpts')) hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($resp === false) return ['status' => 0, 'error' => $err ?: 'request failed', 'body' => null];
|
||||||
|
$decoded = json_decode($resp, true);
|
||||||
|
return ['status' => $status, 'body' => $decoded, 'raw' => $resp, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a relay-side ticket (+ replies) into the local cache, creating
|
||||||
|
* or updating the linked hyprcrd card. */
|
||||||
|
function supportApplyTicket(array $t): void {
|
||||||
|
$db = Database::connect();
|
||||||
|
$relayId = (int)$t['id'];
|
||||||
|
$issueId = (string)($t['issue_id'] ?? '');
|
||||||
|
$email = strtolower((string)($t['email'] ?? ''));
|
||||||
|
$subject = (string)($t['subject'] ?? '');
|
||||||
|
$status = (string)($t['status'] ?? 'open');
|
||||||
|
$body = (string)($t['body'] ?? '');
|
||||||
|
|
||||||
|
// Look up existing mapping
|
||||||
|
$ex = $db->prepare('SELECT card_id FROM support_tickets WHERE relay_id = :r');
|
||||||
|
$ex->execute(['r' => $relayId]);
|
||||||
|
$cardId = (int)($ex->fetchColumn() ?: 0);
|
||||||
|
|
||||||
|
// Compose the card body from the ticket + reply log (relay is
|
||||||
|
// authoritative on replies too).
|
||||||
|
$replies = $t['replies'] ?? [];
|
||||||
|
$rendered = "**From:** " . $email . "\n"
|
||||||
|
. "**Issue:** " . $issueId . "\n"
|
||||||
|
. "**Status:** " . $status . "\n\n---\n\n"
|
||||||
|
. $body;
|
||||||
|
foreach ($replies as $r) {
|
||||||
|
if (($r['direction'] ?? '') === 'in' && ($r['body'] ?? '') === $body) continue; // skip initial dupe
|
||||||
|
$label = ($r['direction'] ?? '') === 'out' ? 'Reply sent' : 'User reply';
|
||||||
|
$when = (string)($r['created_at'] ?? '');
|
||||||
|
$rendered .= "\n\n---\n\n**{$label} — {$when}:**\n\n" . ($r['body'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $subject !== '' ? $subject : '(no subject)';
|
||||||
|
$tagSet = 'support,' . $status;
|
||||||
|
$buckets = ['inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0)];
|
||||||
|
$bucketId = $buckets['inbox'] ?: null;
|
||||||
|
|
||||||
|
if ($cardId) {
|
||||||
|
$db->prepare('UPDATE cards SET title = :t, body = :b, tags = :tg, updated_at = datetime("now") WHERE id = :id')
|
||||||
|
->execute(['t' => $title, 'b' => $rendered, 'tg' => $tagSet, 'id' => $cardId]);
|
||||||
|
} else {
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
|
||||||
|
->execute(['t' => $title, 'b' => $rendered, 'c' => '#38bdf8', 'bi' => $bucketId, 'tg' => $tagSet]);
|
||||||
|
$cardId = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare('
|
||||||
|
INSERT INTO support_tickets (relay_id, card_id, issue_id, email, subject, status, message_id, created_at, updated_at, last_reply_at, polled_at)
|
||||||
|
VALUES (:r, :c, :ii, :e, :s, :st, :mid, :ca, :ua, :lr, datetime("now"))
|
||||||
|
ON CONFLICT(relay_id) DO UPDATE SET
|
||||||
|
card_id = excluded.card_id,
|
||||||
|
issue_id = excluded.issue_id,
|
||||||
|
email = excluded.email,
|
||||||
|
subject = excluded.subject,
|
||||||
|
status = excluded.status,
|
||||||
|
message_id = excluded.message_id,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
last_reply_at = excluded.last_reply_at,
|
||||||
|
polled_at = datetime("now")
|
||||||
|
')->execute([
|
||||||
|
'r' => $relayId,
|
||||||
|
'c' => $cardId,
|
||||||
|
'ii' => $issueId,
|
||||||
|
'e' => $email,
|
||||||
|
's' => $subject,
|
||||||
|
'st' => $status,
|
||||||
|
'mid' => $t['message_id'] ?? null,
|
||||||
|
'ca' => (string)($t['created_at'] ?? date('Y-m-d H:i:s')),
|
||||||
|
'ua' => (string)($t['updated_at'] ?? date('Y-m-d H:i:s')),
|
||||||
|
'lr' => $t['last_reply_at'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Replies: straight upsert by relay reply id. Lets the local card
|
||||||
|
// body stay in sync no matter which side added the reply.
|
||||||
|
$db->prepare('DELETE FROM support_replies WHERE ticket_id = :t')->execute(['t' => $relayId]);
|
||||||
|
$ins = $db->prepare('INSERT INTO support_replies (relay_id, ticket_id, direction, body, sent_at, created_at) VALUES (:r, :t, :d, :b, :sa, :ca)');
|
||||||
|
foreach ($replies as $r) {
|
||||||
|
$ins->execute([
|
||||||
|
'r' => (int)($r['id'] ?? 0) ?: null,
|
||||||
|
't' => $relayId,
|
||||||
|
'd' => (string)($r['direction'] ?? 'in'),
|
||||||
|
'b' => (string)($r['body'] ?? ''),
|
||||||
|
'sa' => $r['sent_at'] ?? null,
|
||||||
|
'ca' => (string)($r['created_at'] ?? date('Y-m-d H:i:s')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
$db = Database::connect();
|
||||||
|
|
||||||
|
if ($route === '/api/support/tickets' && $method === 'GET') {
|
||||||
|
$status = trim((string)($_GET['status'] ?? ''));
|
||||||
|
$sql = 'SELECT t.*, c.title AS card_title FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id';
|
||||||
|
$params = [];
|
||||||
|
if ($status !== '') { $sql .= ' WHERE t.status = :s'; $params['s'] = $status; }
|
||||||
|
$sql .= ' ORDER BY COALESCE(t.last_reply_at, t.updated_at) DESC LIMIT 500';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
supportJson(['tickets' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/support/tickets/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$relayId = (int)$m[1];
|
||||||
|
$stmt = $db->prepare('SELECT t.*, c.title AS card_title, c.body AS card_body FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id WHERE t.relay_id = :r');
|
||||||
|
$stmt->execute(['r' => $relayId]);
|
||||||
|
$t = $stmt->fetch();
|
||||||
|
if (!$t) supportJson(['error' => 'not found'], 404);
|
||||||
|
$reps = $db->prepare('SELECT direction, body, sent_at, created_at FROM support_replies WHERE ticket_id = :t ORDER BY created_at ASC');
|
||||||
|
$reps->execute(['t' => $relayId]);
|
||||||
|
$t['replies'] = $reps->fetchAll();
|
||||||
|
supportJson(['ticket' => $t]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/support/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') {
|
||||||
|
$relayId = (int)$m[1];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
if ($body === '') supportJson(['error' => 'body required'], 400);
|
||||||
|
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/reply', 'POST', ['body' => $body]);
|
||||||
|
if ($res['status'] !== 200) {
|
||||||
|
supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error') . ' (HTTP ' . $res['status'] . ')'], 502);
|
||||||
|
}
|
||||||
|
// Re-fetch the updated ticket so the local cache immediately reflects
|
||||||
|
// the new reply + answered status.
|
||||||
|
$fresh = supportCurl('/api/hyprcrd/tickets?since=', 'GET');
|
||||||
|
if ($fresh['status'] === 200 && is_array($fresh['body']['tickets'] ?? null)) {
|
||||||
|
foreach ($fresh['body']['tickets'] as $t) {
|
||||||
|
if ((int)$t['id'] === $relayId) { supportApplyTicket($t); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
supportJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/support/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$relayId = (int)$m[1];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$status = trim((string)($input['status'] ?? ''));
|
||||||
|
if (!in_array($status, ['open', 'answered', 'closed'], true)) supportJson(['error' => 'invalid status'], 400);
|
||||||
|
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/status', 'PATCH', ['status' => $status]);
|
||||||
|
if ($res['status'] !== 200) supportJson(['error' => 'relay error ' . $res['status']], 502);
|
||||||
|
$db->prepare('UPDATE support_tickets SET status = :s WHERE relay_id = :r')->execute(['s' => $status, 'r' => $relayId]);
|
||||||
|
// Sync the card's support tag set
|
||||||
|
$db->prepare("
|
||||||
|
UPDATE cards SET tags = (
|
||||||
|
SELECT 'support,' || :s FROM support_tickets WHERE card_id = cards.id
|
||||||
|
) WHERE id = (SELECT card_id FROM support_tickets WHERE relay_id = :r)
|
||||||
|
")->execute(['s' => $status, 'r' => $relayId]);
|
||||||
|
supportJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/support/poll' && $method === 'POST') {
|
||||||
|
// Mirrors /api/relay/poll: an unconfigured support relay is a normal
|
||||||
|
// resting state, not an error. Returning 502 here floods the network
|
||||||
|
// tab on every background poll for users who don't use the plugin.
|
||||||
|
if (supportRelayUrl() === '' || supportRelayKey() === '') {
|
||||||
|
supportJson(['ok' => true, 'pulled' => 0]);
|
||||||
|
}
|
||||||
|
$last = supportSetting('last_polled_at', '') ?: '';
|
||||||
|
$qs = $last !== '' ? '?since=' . urlencode($last) : '';
|
||||||
|
$res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');
|
||||||
|
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error'), 'status' => $res['status']], 502);
|
||||||
|
$tickets = $res['body']['tickets'] ?? [];
|
||||||
|
$maxUpd = $last;
|
||||||
|
foreach ($tickets as $t) {
|
||||||
|
supportApplyTicket($t);
|
||||||
|
if (!empty($t['updated_at']) && $t['updated_at'] > $maxUpd) $maxUpd = $t['updated_at'];
|
||||||
|
}
|
||||||
|
if ($maxUpd !== $last) supportSetSetting('last_polled_at', $maxUpd);
|
||||||
|
supportJson(['ok' => true, 'pulled' => count($tickets)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KB search (local cards in the configured KB bucket)
|
||||||
|
if ($route === '/api/support/kb' && $method === 'GET') {
|
||||||
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
|
||||||
|
if (!$bucket) supportJson(['fragments' => []]);
|
||||||
|
$sql = 'SELECT id, title, body, tags, updated_at FROM cards WHERE bucket_id = :b';
|
||||||
|
$params = ['b' => $bucket];
|
||||||
|
if ($q !== '') {
|
||||||
|
$sql .= ' AND (title LIKE :q OR body LIKE :q2 OR tags LIKE :q3)';
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$params['q'] = $like; $params['q2'] = $like; $params['q3'] = $like;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY updated_at DESC LIMIT 50';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
supportJson(['fragments' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/support/kb' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$title = trim((string)($input['title'] ?? ''));
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
$tags = trim((string)($input['tags'] ?? 'faq'));
|
||||||
|
if ($body === '') supportJson(['error' => 'body required'], 400);
|
||||||
|
if ($title === '') $title = mb_substr(str_replace("\n", ' ', $body), 0, 50);
|
||||||
|
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
|
||||||
|
if (!$bucket) supportJson(['error' => 'KB bucket not configured'], 400);
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
|
||||||
|
->execute(['t' => $title, 'b' => $body, 'c' => '#4ade80', 'bi' => $bucket, 'tg' => $tags]);
|
||||||
|
supportJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/support/settings' && $method === 'GET') {
|
||||||
|
supportJson([
|
||||||
|
'relay_url' => supportRelayUrl(),
|
||||||
|
'has_key' => supportRelayKey() !== '',
|
||||||
|
'bucket_inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0),
|
||||||
|
'bucket_kb' => (int)(supportSetting('bucket_kb', '0') ?: 0),
|
||||||
|
'last_polled_at' => supportSetting('last_polled_at', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/support/settings' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
if (array_key_exists('relay_url', $input)) supportSetSetting('relay_url', rtrim((string)$input['relay_url'], '/'));
|
||||||
|
if (array_key_exists('relay_key', $input)) {
|
||||||
|
$k = (string)$input['relay_key'];
|
||||||
|
if ($k === '') supportSetSetting('relay_key_enc', null);
|
||||||
|
else supportSetSetting('relay_key_enc', supportEncrypt($k));
|
||||||
|
}
|
||||||
|
if (array_key_exists('bucket_inbox', $input)) supportSetSetting('bucket_inbox', (int)$input['bucket_inbox'] ?: null);
|
||||||
|
if (array_key_exists('bucket_kb', $input)) supportSetSetting('bucket_kb', (int)$input['bucket_kb'] ?: null);
|
||||||
|
supportJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/support/settings/test' && $method === 'POST') {
|
||||||
|
$res = supportCurl('/api/hyprcrd/ping', 'GET');
|
||||||
|
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'ping failed'), 'status' => $res['status']], 502);
|
||||||
|
supportJson(['ok' => true, 'brand' => $res['body']['brand'] ?? '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy to the relay's editable-settings endpoint. The plugin UI
|
||||||
|
// uses this to let the admin tweak the mail footer / public URL /
|
||||||
|
// brand post-setup without touching the relay's data directly.
|
||||||
|
if ($route === '/api/support/relay-settings' && $method === 'GET') {
|
||||||
|
$res = supportCurl('/api/hyprcrd/settings', 'GET');
|
||||||
|
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay unreachable'), 'status' => $res['status']], 502);
|
||||||
|
supportJson($res['body']);
|
||||||
|
}
|
||||||
|
if ($route === '/api/support/relay-settings' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$res = supportCurl('/api/hyprcrd/settings', 'POST', $input);
|
||||||
|
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay update failed'), 'status' => $res['status']], 502);
|
||||||
|
supportJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook into the central sync.tick so support polls happen alongside the
|
||||||
|
// CalDAV + relay-mail cycle. Runs server-side triggered from the client
|
||||||
|
// — no separate cron needed. If the relay isn't configured the call
|
||||||
|
// silently no-ops.
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
// Tie the poll to the main relay poll: when hyprcrd fires /api/relay/poll
|
||||||
|
// the client also fires /api/support/poll. No server-side hook runs
|
||||||
|
// here, we just expose the endpoint. See support.js.
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "support",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Central support channel: public form for incoming tickets (OUMTA-nnn issue IDs, email-matched replies), plugin inbox with a reply composer, KB-fragment library. Outbound replies via configured SMTP.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["support.js"],
|
||||||
|
"css": ["support.css"],
|
||||||
|
"html": ["support.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/* Support overlay. Full-screen like chat / email / canvas. Dark chrome,
|
||||||
|
* two-column layout on desktop, stacked on narrow. Inbox left, ticket
|
||||||
|
* detail + reply composer + KB picker on the right. */
|
||||||
|
|
||||||
|
.support-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
background: var(--t-bg);
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.support-overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.support-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
}
|
||||||
|
.support-header-left, .support-header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.support-header i { font-size: 16px; color: var(--t-accent); }
|
||||||
|
|
||||||
|
.support-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.support-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
.support-btn-primary {
|
||||||
|
background: var(--t-accent);
|
||||||
|
color: var(--t-bg);
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.support-btn-primary:hover { filter: brightness(1.08); color: var(--t-bg); }
|
||||||
|
.support-btn-danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
|
||||||
|
.support-btn-ghost {
|
||||||
|
width: 30px; height: 30px; padding: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-filter { display: flex; gap: 4px; }
|
||||||
|
.support-pill {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.support-pill:hover { color: var(--t-bright); }
|
||||||
|
.support-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
|
||||||
|
|
||||||
|
.support-body {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.support-body { grid-template-columns: 1fr; }
|
||||||
|
.support-list { max-height: 40vh; }
|
||||||
|
.support-list.hidden-mobile { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-list {
|
||||||
|
border-right: 1px solid var(--t-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--t-bg);
|
||||||
|
}
|
||||||
|
.support-ticket {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.support-ticket:hover { background: var(--t-surface); }
|
||||||
|
.support-ticket.active { background: color-mix(in srgb, var(--t-accent) 12%, transparent); border-left: 3px solid var(--t-accent); padding-left: 11px; }
|
||||||
|
.support-ticket-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||||
|
.support-ticket-issue {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.support-ticket-status {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.support-ticket-status[data-status="open"] { background: rgba(251, 146, 60, 0.2); color: #fb923c; }
|
||||||
|
.support-ticket-status[data-status="answered"] { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||||
|
.support-ticket-status[data-status="closed"] { background: rgba(100, 116, 139, 0.2); color: var(--t-dim); }
|
||||||
|
.support-ticket-title { color: var(--t-bright); font-size: 13px; line-height: 1.3; }
|
||||||
|
.support-ticket-meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.support-detail {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.support-empty {
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Hero empty-state ("pick a ticket on the left") matches the board's
|
||||||
|
* "no cards found" placeholder: centred in the available column, pixel
|
||||||
|
* font with blink cursor, 4xl dim icon above. flex:1 consumes the
|
||||||
|
* detail column so the axis centring actually has a box to work in. */
|
||||||
|
.support-empty-hero {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.support-empty-hero i {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.25rem; /* matches text-4xl */
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-bottom: 1rem; /* matches mb-4 */
|
||||||
|
}
|
||||||
|
.support-empty-hero p {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive));
|
||||||
|
font-size: 0.875rem; /* text-sm */
|
||||||
|
color: var(--t-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-ticket-header h2 { font-family: var(--t-font-pixel); color: var(--t-bright); font-size: 14px; margin: 0 0 4px; }
|
||||||
|
.support-ticket-header .meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.support-ticket-header .email { color: var(--t-text); }
|
||||||
|
|
||||||
|
.support-thread { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.support-msg {
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.support-msg[data-dir="out"] {
|
||||||
|
border-left: 3px solid var(--t-accent);
|
||||||
|
padding-left: 11px;
|
||||||
|
}
|
||||||
|
.support-msg[data-dir="in"] {
|
||||||
|
border-left: 3px solid #fb923c;
|
||||||
|
padding-left: 11px;
|
||||||
|
}
|
||||||
|
.support-msg-label {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.support-msg-label .tag { color: var(--t-accent); }
|
||||||
|
.support-msg-label[data-dir="in"] .tag { color: #fb923c; }
|
||||||
|
|
||||||
|
/* Composer */
|
||||||
|
.support-composer {
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.support-composer-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.support-composer-head .font-pixel { font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.support-kb-search {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--t-font-content, monospace);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.support-kb-search:focus { border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.support-kb-results {
|
||||||
|
display: none;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.support-kb-results.open { display: block; }
|
||||||
|
.support-kb-result {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--t-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.support-kb-result:hover { background: var(--t-surface); }
|
||||||
|
.support-kb-result:last-child { border-bottom: none; }
|
||||||
|
.support-kb-result-title { color: var(--t-bright); font-size: 12px; font-weight: 600; }
|
||||||
|
.support-kb-result-preview { color: var(--t-dim); font-size: 11px; margin-top: 2px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
|
||||||
|
.support-composer textarea {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--t-font-content, ui-monospace, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 140px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.support-composer textarea:focus { border-color: var(--t-accent); }
|
||||||
|
|
||||||
|
.support-composer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.support-composer-actions .spacer { flex: 1; }
|
||||||
|
.support-composer-status { font-size: 11px; color: var(--t-dim); align-self: center; margin-right: auto; }
|
||||||
|
.support-composer-status.err { color: var(--t-danger); }
|
||||||
|
.support-composer-status.ok { color: #4ade80; }
|
||||||
|
|
||||||
|
/* Settings tab (injected into the main settings overlay) */
|
||||||
|
.support-settings-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||||
|
.support-settings-field label {
|
||||||
|
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.support-settings-field input, .support-settings-field select {
|
||||||
|
background: var(--t-bg);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-bright);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--t-font-content, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.support-settings-field input:focus { border-color: var(--t-accent); }
|
||||||
|
.support-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
|
||||||
|
.support-settings-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!-- Support plugin overlay: ticket inbox + reply composer + KB panel.
|
||||||
|
Rendered into the document root; shown/hidden via the .open class. -->
|
||||||
|
<div id="support-overlay" class="support-overlay">
|
||||||
|
<header class="support-header">
|
||||||
|
<div class="support-header-left">
|
||||||
|
<i class="ph ph-lifebuoy"></i>
|
||||||
|
<span class="font-pixel text-xs text-t-bright">Support inbox</span>
|
||||||
|
<button id="support-poll" class="support-btn support-btn-ghost" title="Poll the relay for new tickets"><i class="ph ph-arrow-clockwise"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="support-header-right">
|
||||||
|
<div class="support-filter">
|
||||||
|
<button class="support-pill active" data-filter="">all</button>
|
||||||
|
<button class="support-pill" data-filter="open">open</button>
|
||||||
|
<button class="support-pill" data-filter="answered">answered</button>
|
||||||
|
<button class="support-pill" data-filter="closed">closed</button>
|
||||||
|
</div>
|
||||||
|
<button id="support-close" class="support-btn support-btn-ghost" title="Close (Esc)"><i class="ph ph-x"></i></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="support-body">
|
||||||
|
<!-- Left: ticket list -->
|
||||||
|
<aside id="support-list" class="support-list">
|
||||||
|
<div class="support-empty">Loading…</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Right: active ticket detail + reply composer -->
|
||||||
|
<section id="support-detail" class="support-detail">
|
||||||
|
<div class="support-empty-hero">
|
||||||
|
<i class="ph ph-envelope-open"></i>
|
||||||
|
<p class="cursor-blink">select a ticket</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// ── Support plugin ──
|
||||||
|
//
|
||||||
|
// Talks to a self-hosted support-relay (see support-relay/ in the repo).
|
||||||
|
// Renders a full-screen overlay with the ticket inbox, the active
|
||||||
|
// ticket's thread, a reply composer with KB-fragment search + insert,
|
||||||
|
// and a "save as fragment" button. KB fragments are plain hyprcrd cards
|
||||||
|
// in the configured KB bucket, searched via the plugin backend.
|
||||||
|
|
||||||
|
const h = window.hyprcrd;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
tickets: [],
|
||||||
|
filter: '', // '', 'open', 'answered', 'closed'
|
||||||
|
activeId: null, // relay_id of the selected ticket
|
||||||
|
kbResults: [],
|
||||||
|
composerDirty: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Overlay lifecycle ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const overlay = document.getElementById('support-overlay');
|
||||||
|
function isOpen() { return overlay?.classList.contains('open'); }
|
||||||
|
function show() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.add('open');
|
||||||
|
refreshTickets();
|
||||||
|
// Hopefully poll right away so the user sees fresh data
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
overlay?.classList.remove('open');
|
||||||
|
state.composerDirty = false;
|
||||||
|
}
|
||||||
|
window._supportToggle = () => (isOpen() ? hide() : show());
|
||||||
|
|
||||||
|
$('support-close')?.addEventListener('click', hide);
|
||||||
|
$('support-poll')?.addEventListener('click', () => poll({ manual: true }));
|
||||||
|
|
||||||
|
// Close with Escape (respects any open child dialogs)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && isOpen()) {
|
||||||
|
e.preventDefault();
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter pills
|
||||||
|
overlay?.querySelectorAll('.support-pill').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
overlay.querySelectorAll('.support-pill').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
state.filter = btn.dataset.filter || '';
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': h?.util?.csrf?.() || '' },
|
||||||
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({ error: 'bad response' }));
|
||||||
|
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll({ manual = false } = {}) {
|
||||||
|
try {
|
||||||
|
const res = await api('api/support/poll', { method: 'POST' });
|
||||||
|
if (manual) toast(`Pulled ${res.pulled || 0} update(s)`);
|
||||||
|
await refreshTickets();
|
||||||
|
if (state.activeId) await refreshActive();
|
||||||
|
} catch (e) {
|
||||||
|
if (manual) toast('Poll failed: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTickets() {
|
||||||
|
try {
|
||||||
|
const res = await api('api/support/tickets');
|
||||||
|
state.tickets = res.tickets || [];
|
||||||
|
renderList();
|
||||||
|
} catch (e) {
|
||||||
|
$('support-list').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">Couldn't load: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActive() {
|
||||||
|
if (!state.activeId) return;
|
||||||
|
try {
|
||||||
|
const res = await api('api/support/tickets/' + encodeURIComponent(state.activeId));
|
||||||
|
renderDetail(res.ticket);
|
||||||
|
} catch (e) {
|
||||||
|
$('support-detail').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const host = $('support-list');
|
||||||
|
if (!host) return;
|
||||||
|
const shown = state.filter ? state.tickets.filter(t => t.status === state.filter) : state.tickets;
|
||||||
|
if (!shown.length) {
|
||||||
|
host.innerHTML = `<div class="support-empty">No tickets${state.filter ? ' with status ' + state.filter : ''}.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.innerHTML = shown.map(t => `
|
||||||
|
<div class="support-ticket ${t.relay_id == state.activeId ? 'active' : ''}" data-id="${t.relay_id}">
|
||||||
|
<div class="support-ticket-row">
|
||||||
|
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||||
|
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="support-ticket-title">${escapeHtml(t.subject || '(no subject)')}</div>
|
||||||
|
<div class="support-ticket-meta">
|
||||||
|
<span>${escapeHtml(t.email)}</span>
|
||||||
|
<span>${escapeHtml(formatRel(t.last_reply_at || t.updated_at))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
host.querySelectorAll('.support-ticket').forEach(el => {
|
||||||
|
el.addEventListener('click', () => openTicket(parseInt(el.dataset.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(t) {
|
||||||
|
const host = $('support-detail');
|
||||||
|
if (!host) return;
|
||||||
|
if (!t) {
|
||||||
|
host.innerHTML = `<div class="support-empty-hero"><i class="ph ph-envelope-open"></i><p class="cursor-blink">select a ticket</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const replies = (t.replies || []).map(r => `
|
||||||
|
<div class="support-msg" data-dir="${escapeHtml(r.direction)}">
|
||||||
|
<div class="support-msg-label" data-dir="${escapeHtml(r.direction)}">
|
||||||
|
<span class="tag">${r.direction === 'out' ? 'sent' : 'received'}</span>
|
||||||
|
<span>${escapeHtml(r.created_at)}</span>
|
||||||
|
</div>${escapeHtml(r.body)}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="support-ticket-header">
|
||||||
|
<h2>${escapeHtml(t.subject || '(no subject)')}</h2>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||||
|
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||||
|
<span class="email">${escapeHtml(t.email)}</span>
|
||||||
|
<span>opened ${escapeHtml(formatRel(t.created_at))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="support-thread">
|
||||||
|
${replies || '<div class="support-empty" style="padding:12px;">No messages yet.</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="support-composer">
|
||||||
|
<div class="support-composer-head">
|
||||||
|
<span class="font-pixel">Reply</span>
|
||||||
|
<input class="support-kb-search" placeholder="Search fragments…" id="support-kb-input">
|
||||||
|
<button class="support-btn support-btn-ghost" id="support-kb-toggle" title="Toggle fragment list"><i class="ph ph-books"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="support-kb-results" class="support-kb-results"></div>
|
||||||
|
<textarea id="support-reply-body" placeholder="Type your reply. Click a fragment to insert it at the cursor."></textarea>
|
||||||
|
<div class="support-composer-actions">
|
||||||
|
<span id="support-composer-status" class="support-composer-status"></span>
|
||||||
|
<button class="support-btn" id="support-save-fragment"><i class="ph ph-plus-circle"></i> Save as fragment</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="support-btn" id="support-status-closed" title="Close without replying"><i class="ph ph-archive"></i> Close ticket</button>
|
||||||
|
<button class="support-btn support-btn-primary" id="support-send-btn"><i class="ph ph-paper-plane-tilt"></i> Send reply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
bindComposer(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindComposer(t) {
|
||||||
|
const kbInput = $('support-kb-input');
|
||||||
|
const kbResults = $('support-kb-results');
|
||||||
|
const kbToggle = $('support-kb-toggle');
|
||||||
|
const body = $('support-reply-body');
|
||||||
|
const sendBtn = $('support-send-btn');
|
||||||
|
const saveFrag = $('support-save-fragment');
|
||||||
|
const closeBtn = $('support-status-closed');
|
||||||
|
const statusEl = $('support-composer-status');
|
||||||
|
|
||||||
|
// KB search: debounced
|
||||||
|
let kbTimer = null;
|
||||||
|
async function runKb(q) {
|
||||||
|
try {
|
||||||
|
const res = await api('api/support/kb?q=' + encodeURIComponent(q || ''));
|
||||||
|
state.kbResults = res.fragments || [];
|
||||||
|
kbResults.classList.toggle('open', !!state.kbResults.length);
|
||||||
|
kbResults.innerHTML = state.kbResults.map((f, i) => `
|
||||||
|
<div class="support-kb-result" data-idx="${i}">
|
||||||
|
<div class="support-kb-result-title">${escapeHtml(f.title)}</div>
|
||||||
|
<div class="support-kb-result-preview">${escapeHtml(stripMd(f.body).slice(0, 140))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || `<div class="support-empty" style="padding:12px;">No fragments yet.</div>`;
|
||||||
|
kbResults.querySelectorAll('.support-kb-result').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const frag = state.kbResults[parseInt(el.dataset.idx)];
|
||||||
|
insertAtCursor(body, frag.body);
|
||||||
|
kbResults.classList.remove('open');
|
||||||
|
kbInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
kbResults.innerHTML = `<div class="support-empty" style="padding:12px;color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||||
|
kbResults.classList.add('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kbInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(kbTimer);
|
||||||
|
kbTimer = setTimeout(() => runKb(kbInput.value.trim()), 120);
|
||||||
|
});
|
||||||
|
kbInput.addEventListener('focus', () => runKb(kbInput.value.trim()));
|
||||||
|
kbToggle.addEventListener('click', () => {
|
||||||
|
if (kbResults.classList.contains('open')) kbResults.classList.remove('open');
|
||||||
|
else runKb(kbInput.value.trim());
|
||||||
|
});
|
||||||
|
// Close KB results when clicking outside
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!overlay.contains(e.target)) return;
|
||||||
|
if (kbResults.contains(e.target) || e.target === kbInput || e.target === kbToggle || kbToggle.contains(e.target)) return;
|
||||||
|
kbResults.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', async () => {
|
||||||
|
const text = body.value.trim();
|
||||||
|
if (!text) { setStatus('Nothing to send.', 'err'); return; }
|
||||||
|
sendBtn.disabled = true; setStatus('Sending…');
|
||||||
|
try {
|
||||||
|
await api('api/support/tickets/' + t.relay_id + '/reply', { method: 'POST', body: { body: text } });
|
||||||
|
body.value = '';
|
||||||
|
setStatus('Sent.', 'ok');
|
||||||
|
await refreshTickets();
|
||||||
|
await refreshActive();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Failed: ' + e.message, 'err');
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
saveFrag.addEventListener('click', async () => {
|
||||||
|
const text = body.value.trim();
|
||||||
|
if (!text) { setStatus('Type something first.', 'err'); return; }
|
||||||
|
const title = prompt('Fragment title?', text.split('\n')[0].slice(0, 60));
|
||||||
|
if (title === null) return;
|
||||||
|
try {
|
||||||
|
await api('api/support/kb', { method: 'POST', body: { title: title || '', body: text } });
|
||||||
|
setStatus('Saved to fragment library.', 'ok');
|
||||||
|
// Refresh KB list if open
|
||||||
|
if (kbResults.classList.contains('open')) runKb(kbInput.value.trim());
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Save failed: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await api('api/support/tickets/' + t.relay_id + '/status', { method: 'PATCH', body: { status: 'closed' } });
|
||||||
|
await refreshTickets();
|
||||||
|
await refreshActive();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Close failed: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatus(msg, kind = '') {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'support-composer-status' + (kind ? ' ' + kind : '');
|
||||||
|
if (kind === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'support-composer-status'; }, 3500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTicket(id) {
|
||||||
|
state.activeId = id;
|
||||||
|
renderList();
|
||||||
|
await refreshActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip Markdown markers for preview purposes (headings, emphasis). */
|
||||||
|
function stripMd(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/^#+\s+/gm, '')
|
||||||
|
.replace(/[*_`]/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-friendly timestamp: "just now", "14m ago", "2h ago", "Tue 14:02". */
|
||||||
|
function formatRel(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso.replace(' ', 'T') + (iso.includes('T') || iso.endsWith('Z') ? '' : 'Z'));
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const now = new Date();
|
||||||
|
const diff = (now - d) / 1000;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
if (diff < 86400 * 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAtCursor(textarea, text) {
|
||||||
|
const start = textarea.selectionStart, end = textarea.selectionEnd;
|
||||||
|
const before = textarea.value.slice(0, start);
|
||||||
|
const after = textarea.value.slice(end);
|
||||||
|
const prefix = (before && !before.endsWith('\n')) ? '\n' : '';
|
||||||
|
textarea.value = before + prefix + text + after;
|
||||||
|
const caret = (before + prefix + text).length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(caret, caret);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, kind = 'info') {
|
||||||
|
// Piggy-back on the simple notification system from relay sync
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;font-family:var(--t-font-pixel);font-size:10px;color:' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 350); }, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header button ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function injectHeaderButton() {
|
||||||
|
const tasksBtn = document.getElementById('tasks-btn');
|
||||||
|
if (!tasksBtn || document.getElementById('support-btn')) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'support-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-lifebuoy text-sm"></i><span class="font-pixel text-[10px]">Support</span>';
|
||||||
|
btn.addEventListener('click', () => window._supportToggle?.());
|
||||||
|
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', injectHeaderButton);
|
||||||
|
} else {
|
||||||
|
injectHeaderButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings tab in the main settings overlay ───────────────────
|
||||||
|
|
||||||
|
function renderSettingsPanel(panelEl) {
|
||||||
|
panelEl.innerHTML = `
|
||||||
|
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||||
|
Connect hyprcrd to your support relay. The relay hosts the public form and
|
||||||
|
sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply
|
||||||
|
from any card.
|
||||||
|
</p>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-set-url">Relay URL</label>
|
||||||
|
<input id="support-set-url" type="url" placeholder="https://support.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-set-key">API key</label>
|
||||||
|
<input id="support-set-key" type="password" placeholder="paste the key shown by the relay setup" autocomplete="off">
|
||||||
|
<div class="support-settings-help">Leave empty to keep the currently stored key.</div>
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-set-inbox">Inbox bucket</label>
|
||||||
|
<select id="support-set-inbox"><option value="0">(none — keep cards unsorted)</option></select>
|
||||||
|
<div class="support-settings-help">Where newly-pulled ticket cards will land.</div>
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-set-kb">KB bucket</label>
|
||||||
|
<select id="support-set-kb"><option value="0">(none — disable fragment library)</option></select>
|
||||||
|
<div class="support-settings-help">Cards in this bucket become reusable reply fragments.</div>
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-row">
|
||||||
|
<button id="support-set-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save</button>
|
||||||
|
<button id="support-set-test" class="support-btn"><i class="ph ph-pulse"></i> Test connection</button>
|
||||||
|
<span id="support-set-status" class="support-settings-help"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:20px;font-family:var(--t-font-pixel);font-size:12px;color:var(--t-accent);text-transform:uppercase;letter-spacing:0.5px;">Relay config</h2>
|
||||||
|
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||||
|
Editable post-setup values on the connected relay. Mail footer supports
|
||||||
|
<code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>
|
||||||
|
placeholders — lines containing an empty placeholder are dropped.
|
||||||
|
</p>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-rs-brand">Brand</label>
|
||||||
|
<input id="support-rs-brand" type="text">
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-rs-public">Public form URL</label>
|
||||||
|
<input id="support-rs-public" type="url" placeholder="https://support.oumta.de">
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-field">
|
||||||
|
<label for="support-rs-footer">Mail footer template</label>
|
||||||
|
<textarea id="support-rs-footer" rows="4" style="background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:8px 10px;border-radius:3px;font-family:var(--t-font-content,monospace);font-size:12px;outline:none;resize:vertical;"></textarea>
|
||||||
|
<div class="support-settings-help" id="support-rs-footer-default"></div>
|
||||||
|
</div>
|
||||||
|
<div class="support-settings-row">
|
||||||
|
<button id="support-rs-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save relay config</button>
|
||||||
|
<span id="support-rs-status" class="support-settings-help"></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Populate bucket selects from state.buckets
|
||||||
|
const opts = (window.state?.buckets || []).map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`).join('');
|
||||||
|
panelEl.querySelector('#support-set-inbox').innerHTML += opts;
|
||||||
|
panelEl.querySelector('#support-set-kb').innerHTML += opts;
|
||||||
|
|
||||||
|
api('api/support/settings').then(cur => {
|
||||||
|
panelEl.querySelector('#support-set-url').value = cur.relay_url || '';
|
||||||
|
panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup';
|
||||||
|
if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox;
|
||||||
|
if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb;
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
const statusEl = panelEl.querySelector('#support-set-status');
|
||||||
|
function setStatus(msg, kind = '') {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
||||||
|
}
|
||||||
|
|
||||||
|
panelEl.querySelector('#support-set-save').addEventListener('click', async () => {
|
||||||
|
const payload = {
|
||||||
|
relay_url: panelEl.querySelector('#support-set-url').value.trim(),
|
||||||
|
bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0,
|
||||||
|
bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0,
|
||||||
|
};
|
||||||
|
const key = panelEl.querySelector('#support-set-key').value;
|
||||||
|
if (key) payload.relay_key = key;
|
||||||
|
try {
|
||||||
|
await api('api/support/settings', { method: 'POST', body: payload });
|
||||||
|
setStatus('Saved.', 'ok');
|
||||||
|
} catch (e) { setStatus(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
panelEl.querySelector('#support-set-test').addEventListener('click', async () => {
|
||||||
|
setStatus('Testing…');
|
||||||
|
try {
|
||||||
|
const r = await api('api/support/settings/test', { method: 'POST' });
|
||||||
|
setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok');
|
||||||
|
} catch (e) { setStatus(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relay config (brand / public url / footer). Loaded after a tick so
|
||||||
|
// the local-settings render happens first — keeps this lazy, and
|
||||||
|
// silently hides if the relay isn't reachable.
|
||||||
|
const rsStatus = panelEl.querySelector('#support-rs-status');
|
||||||
|
function setRsStatus(msg, kind = '') {
|
||||||
|
rsStatus.textContent = msg;
|
||||||
|
rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rs = await api('api/support/relay-settings');
|
||||||
|
panelEl.querySelector('#support-rs-brand').value = rs.brand || '';
|
||||||
|
panelEl.querySelector('#support-rs-public').value = rs.public_url || '';
|
||||||
|
panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || '';
|
||||||
|
const def = panelEl.querySelector('#support-rs-footer-default');
|
||||||
|
if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ ');
|
||||||
|
} catch (e) {
|
||||||
|
setRsStatus('(relay unreachable — configure above first)', 'err');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
panelEl.querySelector('#support-rs-save').addEventListener('click', async () => {
|
||||||
|
setRsStatus('Saving…');
|
||||||
|
try {
|
||||||
|
await api('api/support/relay-settings', { method: 'POST', body: {
|
||||||
|
brand: panelEl.querySelector('#support-rs-brand').value.trim(),
|
||||||
|
public_url: panelEl.querySelector('#support-rs-public').value.trim(),
|
||||||
|
mail_footer: panelEl.querySelector('#support-rs-footer').value,
|
||||||
|
}});
|
||||||
|
setRsStatus('Saved.', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setRsStatus(e.message, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
h?.settings?.registerTab?.({
|
||||||
|
id: 'support',
|
||||||
|
label: 'support',
|
||||||
|
icon: 'ph-lifebuoy',
|
||||||
|
onShow: renderSettingsPanel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook the sync tick so each user-triggered sync also pulls support.
|
||||||
|
h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); });
|
||||||
|
|
||||||
|
h?.registerPlugin?.({ name: 'support', version: '1.0.0' });
|
||||||
@@ -203,6 +203,10 @@ register('search', 'search <query> — open search with query', (args) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', '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('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
|
||||||
|
|
||||||
register('export', 'download export ZIP', () => {
|
register('export', 'download export ZIP', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
async function makeCard(title, body) {
|
async function makeCard(title, body) {
|
||||||
return fetch('api/cards', {
|
return fetch('api/cards', {
|
||||||
@@ -70,22 +70,25 @@ describe('Search: Find/Replace', () => {
|
|||||||
const applyBtn = document.getElementById('search-replace-all');
|
const applyBtn = document.getElementById('search-replace-all');
|
||||||
expect(applyBtn.disabled).toBe(false);
|
expect(applyBtn.disabled).toBe(false);
|
||||||
click(applyBtn);
|
click(applyBtn);
|
||||||
await sleep(200);
|
|
||||||
|
|
||||||
const dialog = document.getElementById('dialog');
|
// The replace-all button opens a confirm dialog — wait for it
|
||||||
if (dialog?.classList.contains('open')) {
|
// to appear rather than relying on a fixed sleep, since the
|
||||||
const okBtn = document.getElementById('dialog-ok');
|
// dialog creation is async and can lag on slow runs.
|
||||||
if (okBtn) click(okBtn);
|
await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
|
||||||
}
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
|
if (okBtn) click(okBtn);
|
||||||
|
|
||||||
// Poll until the replace has actually persisted
|
// Poll until both replaces have persisted. Budget is generous
|
||||||
|
// (~10s) because a cold PHP run plus two sequential PATCH calls
|
||||||
|
// can easily blow past the original 4-second window — the retry
|
||||||
|
// always passes because opcache + SQLite WAL are warm by then.
|
||||||
let updated1, updated2;
|
let updated1, updated2;
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
updated1 = await getCard(c1.id);
|
updated1 = await getCard(c1.id);
|
||||||
if (updated1?.body === 'goodbye world goodbye') break;
|
updated2 = await getCard(c2.id);
|
||||||
|
if (updated1?.body === 'goodbye world goodbye' && updated2?.body === 'goodbye there') break;
|
||||||
}
|
}
|
||||||
updated2 = await getCard(c2.id);
|
|
||||||
expect(updated1.body).toBe('goodbye world goodbye');
|
expect(updated1.body).toBe('goodbye world goodbye');
|
||||||
expect(updated2.body).toBe('goodbye there');
|
expect(updated2.body).toBe('goodbye there');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 persistence', () => {
|
||||||
|
it('new columns survive close + reopen', async () => {
|
||||||
|
let card;
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_colsave__', body: emptyBody() }) }).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// Open overlay, add a column via the data model + save
|
||||||
|
// (simulates what addColumn does without the dialog).
|
||||||
|
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||||
|
click(el);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
// Grab the plugin's internal data via a fresh body parse,
|
||||||
|
// add a column, and save through the overlay's save path.
|
||||||
|
const full = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||||
|
const body = (full[0]?.body || '').trim();
|
||||||
|
const jsonEnd = body.lastIndexOf('}');
|
||||||
|
const parsed = JSON.parse(body.substring(0, jsonEnd + 1));
|
||||||
|
parsed.columns.push({ name: 'Added', items: [] });
|
||||||
|
const newBody = JSON.stringify(parsed) + '\n\n#bookmarks';
|
||||||
|
await fetch(`api/cards/${card.id}`, {
|
||||||
|
method: 'PATCH', headers: hJson(),
|
||||||
|
body: JSON.stringify({ body: newBody }),
|
||||||
|
});
|
||||||
|
// Update local state like save() does.
|
||||||
|
const local = (window.state?.cards || []).find(c => c.id == card.id);
|
||||||
|
if (local) local.body = newBody;
|
||||||
|
|
||||||
|
click($('bookmarks-close'));
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Reopen — the new column must still be there.
|
||||||
|
const el2 = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||||
|
click(el2);
|
||||||
|
await sleep(150);
|
||||||
|
const names = [...document.querySelectorAll('.bm-col-name')].map(n => n.textContent);
|
||||||
|
expect(names).toContain('General');
|
||||||
|
expect(names).toContain('Added');
|
||||||
|
click($('bookmarks-close'));
|
||||||
|
await sleep(80);
|
||||||
|
} finally {
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -392,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js?v=4';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Detail View', () => {
|
describe('Detail View', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
@@ -71,3 +71,90 @@ describe('Detail View', () => {
|
|||||||
expect($('#detail')).toBeHidden();
|
expect($('#detail')).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Detail: open-switches-bucket', () => {
|
||||||
|
const SETTING_KEY = 'hyprcrd-open-switches-bucket';
|
||||||
|
const BUCKET_KEY = 'hyprcrd-active-bucket';
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await closeAll();
|
||||||
|
// Restore runner baseline.
|
||||||
|
window.state.activeBucket = 'all';
|
||||||
|
localStorage.setItem(BUCKET_KEY, 'all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to the card bucket when the setting is on', async () => {
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
if (!Array.isArray(buckets) || !buckets.length) return;
|
||||||
|
// The bucket bar renders top-level non-super buckets with
|
||||||
|
// `data-bucket=<id>`, super-bucket parents with `data-bucket-super`,
|
||||||
|
// and hides child buckets behind the parent's dropdown. To keep
|
||||||
|
// this test robust across different user bucket layouts, pick two
|
||||||
|
// plain top-level buckets (no children, no parent). Skip when the
|
||||||
|
// fixture doesn't give us two.
|
||||||
|
const childParentIds = new Set(buckets.filter(x => x.parent_id).map(x => x.parent_id));
|
||||||
|
const plainTop = buckets.filter(x => !x.parent_id && !childParentIds.has(x.id));
|
||||||
|
if (plainTop.length < 2) return;
|
||||||
|
const b = plainTop[0];
|
||||||
|
const other = plainTop[1];
|
||||||
|
const token = csrf();
|
||||||
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
||||||
|
let card;
|
||||||
|
const prev = localStorage.getItem(SETTING_KEY);
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: h,
|
||||||
|
body: JSON.stringify({ title: '__test_switch_bucket__', body: '', bucket_id: b.id })
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Start on a DIFFERENT bucket so the switch is visible.
|
||||||
|
window.state.activeBucket = String(other.id);
|
||||||
|
|
||||||
|
// Enable the setting.
|
||||||
|
localStorage.setItem(SETTING_KEY, '1');
|
||||||
|
|
||||||
|
window._openDetail(card.id);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
expect(String(window.state.activeBucket)).toBe(String(b.id));
|
||||||
|
// Bucket bar must highlight the switched-to bucket.
|
||||||
|
const activeTab = document.querySelector('#bucket-bar .bucket-tab.active[data-bucket]');
|
||||||
|
expect(activeTab).toExist();
|
||||||
|
expect(activeTab.dataset.bucket).toBe(String(b.id));
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem(SETTING_KEY, prev ?? '0');
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT switch when the setting is off', async () => {
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
if (!Array.isArray(buckets) || buckets.length < 2) return;
|
||||||
|
const token = csrf();
|
||||||
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
||||||
|
let card;
|
||||||
|
const prev = localStorage.getItem(SETTING_KEY);
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: h,
|
||||||
|
body: JSON.stringify({ title: '__test_no_switch__', body: '', bucket_id: buckets[0].id })
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
window.state.activeBucket = String(buckets[1].id);
|
||||||
|
localStorage.setItem(SETTING_KEY, '0');
|
||||||
|
|
||||||
|
window._openDetail(card.id);
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Should stay on the other bucket.
|
||||||
|
expect(String(window.state.activeBucket)).toBe(String(buckets[1].id));
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem(SETTING_KEY, prev ?? '0');
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,9 +143,10 @@ describe('Line Numbers: Alignment with Textarea', () => {
|
|||||||
const cs = getComputedStyle(body);
|
const cs = getComputedStyle(body);
|
||||||
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||||
const textTotal = body.scrollHeight - pad;
|
const textTotal = body.scrollHeight - pad;
|
||||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering;
|
// textarea scrollHeight and mirror-div sums diverge due to internal rendering,
|
||||||
// allow 50% relative tolerance — scroll sync handles the rest
|
// font metrics, and OS-level text shaping — allow 60% relative tolerance.
|
||||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.5;
|
// Scroll sync (proportional mapping) handles the real alignment at runtime.
|
||||||
|
const tolerance = Math.max(textTotal, gutterTotal) * 0.6;
|
||||||
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
|
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { describe, it, expect, csrf } from './runner.js?v=4';
|
|||||||
describe('Relay: Push API', () => {
|
describe('Relay: Push API', () => {
|
||||||
it('push without config returns error', async () => {
|
it('push without config returns error', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
// The GET endpoint echoes the secret directly as `key` (single-user local app).
|
// GET only reports `has_key` (boolean) — the secret itself is never
|
||||||
if (settings.url && settings.key) return; // skip if configured
|
// echoed. If a relay is configured the real push may succeed or fail
|
||||||
|
// for other reasons, so skip in that case.
|
||||||
|
if (settings.url && settings.has_key) return; // skip if configured
|
||||||
|
|
||||||
const res = await fetch('api/relay/push', {
|
const res = await fetch('api/relay/push', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
@@ -16,7 +18,7 @@ describe('Relay: Push API', () => {
|
|||||||
|
|
||||||
it('push without card_id returns error', async () => {
|
it('push without card_id returns error', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
if (!settings.url || !settings.key) return; // card_id check is after config check
|
if (!settings.url || !settings.has_key) return; // card_id check is after config check
|
||||||
const res = await fetch('api/relay/push', {
|
const res = await fetch('api/relay/push', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
@@ -28,7 +30,7 @@ describe('Relay: Push API', () => {
|
|||||||
describe('Relay: Poll API', () => {
|
describe('Relay: Poll API', () => {
|
||||||
it('poll without config returns 0 pulled', async () => {
|
it('poll without config returns 0 pulled', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
if (settings.url && settings.key) return;
|
if (settings.url && settings.has_key) return;
|
||||||
|
|
||||||
const res = await fetch('api/relay/poll', {
|
const res = await fetch('api/relay/poll', {
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
||||||
|
|||||||
@@ -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,6 +170,71 @@ 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');
|
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']) {
|
||||||
@@ -452,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);
|
||||||
@@ -476,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) {
|
||||||
@@ -508,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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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 uses checkboxes so multiple lists can be active.
|
||||||
|
const checkboxes = overlay.querySelectorAll('input[type="checkbox"]');
|
||||||
|
expect(checkboxes.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,9 +140,13 @@ describe('Terminal: Commands', () => {
|
|||||||
termToggle();
|
termToggle();
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
termExec('new __term_new_card__');
|
termExec('new __term_new_card__');
|
||||||
await sleep(400);
|
// termExec fires off an async API call + full reload; poll rather
|
||||||
const out = termOutput();
|
// than sleeping a fixed amount so slow machines don't flake.
|
||||||
expect(out).toContain('created');
|
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());
|
const cards = await fetch('api/cards').then(r => r.json());
|
||||||
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
|
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -53,6 +54,9 @@ const FILES = [
|
|||||||
'./asset-picker.test.js',
|
'./asset-picker.test.js',
|
||||||
'./terminal.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.
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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', async () => {
|
||||||
|
openTim();
|
||||||
|
await sleep(30);
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,11 +109,17 @@ describe('Undo: Editor Save', () => {
|
|||||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
click('#modal-save-close');
|
click('#modal-save-close');
|
||||||
|
|
||||||
|
// Wait for both the server patch AND the modal close before firing
|
||||||
|
// Ctrl+Z — the global undo shortcut is guarded by
|
||||||
|
// `!guards.isOpen('modal')`, so if doSave's closeModal() hasn't run
|
||||||
|
// yet (it only fires after the post-save _reloadAll), the undo key
|
||||||
|
// is swallowed silently and the test races the client's save flow.
|
||||||
let card;
|
let card;
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
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;
|
const modalClosed = !document.getElementById('modal')?.classList.contains('open');
|
||||||
|
if ((card?.body || '').includes('after edit') && modalClosed) break;
|
||||||
}
|
}
|
||||||
expect(card.body).toContain('after edit');
|
expect(card.body).toContain('after edit');
|
||||||
|
|
||||||
@@ -160,11 +166,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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -357,7 +363,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);
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+9
-1
@@ -24,7 +24,9 @@ function getSessionTenant(PDO $db): ?array {
|
|||||||
if (!$token) return null;
|
if (!$token) return null;
|
||||||
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
||||||
$stmt->execute(['tok' => $token]);
|
$stmt->execute(['tok' => $token]);
|
||||||
return $stmt->fetch() ?: null;
|
$tenant = $stmt->fetch() ?: null;
|
||||||
|
if ($tenant) touchLastSeen($db, (int)$tenant['id']);
|
||||||
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroySession(PDO $db): void {
|
function destroySession(PDO $db): void {
|
||||||
@@ -62,5 +64,11 @@ function authClient(PDO $db): ?array {
|
|||||||
$tenant = getTenantByUsername($db, $username);
|
$tenant = getTenantByUsername($db, $username);
|
||||||
if (!$tenant) return null;
|
if (!$tenant) return null;
|
||||||
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
||||||
|
touchLastSeen($db, (int)$tenant['id']);
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function touchLastSeen(PDO $db, int $tenantId): void {
|
||||||
|
$db->prepare("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = :id")
|
||||||
|
->execute(['id' => $tenantId]);
|
||||||
|
}
|
||||||
+193
-2
@@ -133,6 +133,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS tenants (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)');
|
)');
|
||||||
|
|
||||||
|
// last_seen_at: updated on every authenticated request so the admin
|
||||||
|
// can see when a user was last active.
|
||||||
|
$tenantCols = $db->query("PRAGMA table_info(tenants)")->fetchAll();
|
||||||
|
$tenantColNames = array_column($tenantCols, 'name');
|
||||||
|
if (!in_array('last_seen_at', $tenantColNames)) {
|
||||||
|
$db->exec("ALTER TABLE tenants ADD COLUMN last_seen_at TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
@@ -189,8 +197,106 @@ $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 lists: multiple active cards per tenant, each mirrored from
|
||||||
|
// the client. Phone toggles checkboxes in-place via the session UI.
|
||||||
|
// hyprcrd remains the only surface that edits item text.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT "",
|
||||||
|
body TEXT NOT NULL DEFAULT "",
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (tenant_id, card_uid),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
// Migrate from old single-PK schema (tenant_id only) to composite PK
|
||||||
|
// + ensure position column exists (may be missing on tables created
|
||||||
|
// before the multi-list update).
|
||||||
|
$shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll();
|
||||||
|
$shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0);
|
||||||
|
$shoppingColNames = array_column($shoppingInfo, 'name');
|
||||||
|
if (count($shoppingPk) === 1) {
|
||||||
|
$db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old');
|
||||||
|
$db->exec('CREATE TABLE shopping_state (
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
card_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT "",
|
||||||
|
body TEXT NOT NULL DEFAULT "",
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (tenant_id, card_uid),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old');
|
||||||
|
$db->exec('DROP TABLE _shopping_old');
|
||||||
|
} elseif (!in_array('position', $shoppingColNames)) {
|
||||||
|
$db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
// Clean up __inactive__ sentinel rows from the old single-list era.
|
||||||
|
$db->exec("DELETE FROM shopping_state WHERE card_uid = '__inactive__'");
|
||||||
|
|
||||||
|
// 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 +338,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
|
||||||
@@ -261,6 +381,39 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks (
|
|||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
|
||||||
|
|
||||||
|
// Read-later items: cards the tenant opted into sending to the relay for
|
||||||
|
// distraction-free reading in the mobile zen view. Stored at-rest under the
|
||||||
|
// inter-tenant key (same pattern as messages). Hard-deleted on removal —
|
||||||
|
// no offline-sync need, the hyprcrd client is the source of truth.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS read_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT "",
|
||||||
|
body TEXT NOT NULL DEFAULT "",
|
||||||
|
hero_filename TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (tenant_id, relay_uid),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_read_items_tenant_updated ON read_items(tenant_id, updated_at DESC)');
|
||||||
|
|
||||||
|
// Read attachments (inline images referenced by read items). Encrypted blobs
|
||||||
|
// scoped to a tenant + read_item. The reader UI serves these through a
|
||||||
|
// session endpoint so the browser sees decrypted bytes.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS read_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (tenant_id, relay_uid, filename),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_read_att_lookup ON read_attachments(tenant_id, relay_uid)');
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tenant_id INTEGER NOT NULL,
|
tenant_id INTEGER NOT NULL,
|
||||||
@@ -275,11 +428,49 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
|
|||||||
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)');
|
)');
|
||||||
|
|
||||||
|
// ── v3.x → v1.10 crypto migration ────────────────────────────────
|
||||||
|
// Re-encrypt master-wrapped secrets (tenant client secrets + inter-tenant
|
||||||
|
// key) from CBC-V1 to GCM-V2. This only touches data we already hold the
|
||||||
|
// master key for, so no user action is needed and no secrets change —
|
||||||
|
// only the envelope around them. Idempotent: rows already in V2 skip.
|
||||||
|
require_once __DIR__ . '/crypto.php';
|
||||||
|
if (tableExists($db, 'tenants')) {
|
||||||
|
$legacyTenantRows = $db->query("SELECT id, secret_encrypted FROM tenants WHERE secret_encrypted IS NOT NULL AND secret_encrypted != '' AND secret_encrypted NOT LIKE '" . HYPRCRD_GCM_MAGIC_MASTER . "%'")->fetchAll();
|
||||||
|
if ($legacyTenantRows) {
|
||||||
|
$upd = $db->prepare('UPDATE tenants SET secret_encrypted = :s WHERE id = :i');
|
||||||
|
foreach ($legacyTenantRows as $r) {
|
||||||
|
$plain = masterDecrypt($r['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if ($plain !== '') {
|
||||||
|
$upd->execute(['s' => masterEncrypt($plain, $MASTER_KEY), 'i' => $r['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@error_log('[hyprcrd-relay] migrated ' . count($legacyTenantRows) . ' tenant secret_encrypted rows to GCM-V2');
|
||||||
|
}
|
||||||
|
$legacyItk = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
||||||
|
if ($legacyItk && !str_starts_with($legacyItk, HYPRCRD_GCM_MAGIC_MASTER)) {
|
||||||
|
$plainItk = masterDecrypt($legacyItk, $MASTER_KEY);
|
||||||
|
if ($plainItk !== '') {
|
||||||
|
setInterTenantKey($db, $MASTER_KEY, $plainItk);
|
||||||
|
@error_log('[hyprcrd-relay] migrated inter-tenant key to GCM-V2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
+77
-27
@@ -1,24 +1,54 @@
|
|||||||
<?php
|
<?php
|
||||||
// Crypto helpers: at-rest encryption with master key, plus the
|
// Crypto helpers.
|
||||||
// HYPRCRD-RELAY-V1 envelope format used by both the JS client and
|
//
|
||||||
// the relay's internal re-encryption hops.
|
// Two envelope formats coexist during the v1.10 rollout:
|
||||||
|
//
|
||||||
|
// HYPRCRD-GCM-V2 — new: AES-256-GCM, per-ciphertext random salt + HKDF
|
||||||
|
// (authenticated, standard). Used for every new write.
|
||||||
|
// HYPRCRD-RELAY-V1 — legacy: AES-256-CBC, substr(hex(sha256(secret))) as a
|
||||||
|
// 32-byte ASCII key, no MAC. Dual-read only.
|
||||||
|
//
|
||||||
|
// For at-rest master-key encryption the legacy format carries no magic
|
||||||
|
// prefix (raw base64(iv || cipher)). The new format uses HYPRCRD-MGCM-V2.
|
||||||
|
//
|
||||||
|
// Same user secret works across both formats — only the KDF and cipher
|
||||||
|
// differ. Old data stays readable forever via the V1 fallback; new data
|
||||||
|
// is authenticated and cannot be silently mutated.
|
||||||
|
|
||||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
const HYPRCRD_GCM_MAGIC_CLIENT = 'HYPRCRD-GCM-V2';
|
||||||
|
const HYPRCRD_CBC_MAGIC_CLIENT = 'HYPRCRD-RELAY-V1';
|
||||||
|
const HYPRCRD_GCM_MAGIC_MASTER = 'HYPRCRD-MGCM-V2';
|
||||||
|
const HYPRCRD_HKDF_INFO_CLIENT = 'hyprcrd-relay-client-v2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a value at-rest with the master key. Used for tenant secrets
|
* Encrypt a value at-rest with the master key. The master key is already
|
||||||
* and the inter-tenant key in the DB.
|
* cryptographically random (32 raw bytes), so no KDF stretching is needed —
|
||||||
|
* a fresh 12-byte nonce per ciphertext is enough for GCM.
|
||||||
*/
|
*/
|
||||||
function masterEncrypt(string $plain, string $masterKeyHex): string {
|
function masterEncrypt(string $plain, string $masterKeyHex): string {
|
||||||
$key = hex2bin($masterKeyHex);
|
$key = hex2bin($masterKeyHex);
|
||||||
$iv = random_bytes(16);
|
$nonce = random_bytes(12);
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$tag = '';
|
||||||
return base64_encode($iv . $cipher);
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
return HYPRCRD_GCM_MAGIC_MASTER . "\n" . base64_encode($nonce . $cipher . $tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
||||||
$key = hex2bin($masterKeyHex);
|
$key = hex2bin($masterKeyHex);
|
||||||
$raw = base64_decode($encoded);
|
if (str_starts_with($encoded, HYPRCRD_GCM_MAGIC_MASTER)) {
|
||||||
|
$b64 = trim(substr($encoded, strlen(HYPRCRD_GCM_MAGIC_MASTER) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 12 + 16 + 1) return '';
|
||||||
|
$nonce = substr($raw, 0, 12);
|
||||||
|
$tag = substr($raw, -16);
|
||||||
|
$cipher = substr($raw, 12, -16);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
return $plain !== false ? $plain : '';
|
||||||
|
}
|
||||||
|
// Legacy: base64(iv(16) || ciphertext) under AES-256-CBC, no prefix.
|
||||||
|
$raw = base64_decode($encoded, true);
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
$iv = substr($raw, 0, 16);
|
$iv = substr($raw, 0, 16);
|
||||||
$cipher = substr($raw, 16);
|
$cipher = substr($raw, 16);
|
||||||
@@ -26,27 +56,48 @@ function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match the JS client's encryption format exactly:
|
* Encrypt with a user-chosen secret (client API "shared secret" and the
|
||||||
* key = first 32 chars of hex(SHA-256(secret)) used as 32-byte ASCII key
|
* relay-wide inter-tenant key). V2 format:
|
||||||
* envelope = "HYPRCRD-RELAY-V1\n" + base64(iv || ciphertext)
|
* magic "\n" base64( salt(16) || nonce(12) || ciphertext || tag(16) )
|
||||||
|
* Per-ciphertext salt feeds HKDF(SHA-256) to derive a fresh 32-byte subkey
|
||||||
|
* — defeats the weak legacy substr-of-hex KDF and removes cross-tenant
|
||||||
|
* key collisions for identical secrets.
|
||||||
*/
|
*/
|
||||||
function clientEncrypt(string $plain, string $secret): string {
|
function clientEncrypt(string $plain, string $secret): string {
|
||||||
$key = substr(hash('sha256', $secret), 0, 32);
|
$salt = random_bytes(16);
|
||||||
$iv = random_bytes(16);
|
$nonce = random_bytes(12);
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_HKDF_INFO_CLIENT, $salt);
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
$tag = '';
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
return HYPRCRD_GCM_MAGIC_CLIENT . "\n" . base64_encode($salt . $nonce . $cipher . $tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientDecrypt(string $envelope, string $secret): string {
|
function clientDecrypt(string $envelope, string $secret): string {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return $envelope;
|
if (str_starts_with($envelope, HYPRCRD_GCM_MAGIC_CLIENT)) {
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_GCM_MAGIC_CLIENT) + 1));
|
||||||
$raw = base64_decode($b64);
|
$raw = base64_decode($b64, true);
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
if (!$raw || strlen($raw) < 16 + 12 + 16 + 1) return '';
|
||||||
$key = substr(hash('sha256', $secret), 0, 32);
|
$salt = substr($raw, 0, 16);
|
||||||
$iv = substr($raw, 0, 16);
|
$nonce = substr($raw, 16, 12);
|
||||||
$cipher = substr($raw, 16);
|
$tag = substr($raw, -16);
|
||||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$cipher = substr($raw, 28, -16);
|
||||||
return $result !== false ? $result : '';
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_HKDF_INFO_CLIENT, $salt);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
return $plain !== false ? $plain : '';
|
||||||
|
}
|
||||||
|
if (str_starts_with($envelope, HYPRCRD_CBC_MAGIC_CLIENT)) {
|
||||||
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_CBC_MAGIC_CLIENT) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$key = substr(hash('sha256', $secret), 0, 32);
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$result = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return $result !== false ? $result : '';
|
||||||
|
}
|
||||||
|
// Neither envelope — passthrough. Preserves the old behaviour where
|
||||||
|
// already-plaintext fields flowed through unchanged.
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +105,7 @@ function clientDecrypt(string $envelope, string $secret): string {
|
|||||||
* during initial setup (it's the third field in the setup form). Used to
|
* during initial setup (it's the third field in the setup form). Used to
|
||||||
* re-encrypt messages at rest so they can be routed between tenants.
|
* re-encrypt messages at rest so they can be routed between tenants.
|
||||||
*
|
*
|
||||||
* Returns null if not set yet (e.g. legacy v2 installs that haven't been
|
* Returns null if not set yet.
|
||||||
* upgraded through the new setup wizard).
|
|
||||||
*/
|
*/
|
||||||
function getInterTenantKey(PDO $db, string $masterKey): ?string {
|
function getInterTenantKey(PDO $db, string $masterKey): ?string {
|
||||||
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ if ($route === '/api/account/secret' && $method === 'DELETE') {
|
|||||||
|
|
||||||
if ($route === '/api/account/password' && $method === 'POST') {
|
if ($route === '/api/account/password' && $method === 'POST') {
|
||||||
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
|
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
|
||||||
|
// Rate-limit password changes — a stolen session cookie could otherwise
|
||||||
|
// brute-force the `current` field server-side with no friction.
|
||||||
|
if (isRateLimited($db, 'account-password', 10)) jsonResponse(['error' => 'too many attempts'], 429);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$current = (string)($input['current'] ?? '');
|
$current = (string)($input['current'] ?? '');
|
||||||
$new = (string)($input['new'] ?? '');
|
$new = (string)($input['new'] ?? '');
|
||||||
|
|
||||||
if (strlen($new) < 4) jsonResponse(['error' => 'new password must be at least 4 chars'], 400);
|
if (strlen($new) < 10) jsonResponse(['error' => 'new password must be at least 10 chars'], 400);
|
||||||
if (!password_verify($current, $sessionTenant['password_hash'])) {
|
if (!password_verify($current, $sessionTenant['password_hash'])) {
|
||||||
jsonResponse(['error' => 'current password is wrong'], 401);
|
jsonResponse(['error' => 'current password is wrong'], 401);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
if ($route === '/api/users' && $method === 'GET') {
|
if ($route === '/api/users' && $method === 'GET') {
|
||||||
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
||||||
$rows = $db->query('SELECT id, username, is_admin, created_at FROM tenants ORDER BY id ASC')->fetchAll();
|
$rows = $db->query('SELECT id, username, is_admin, created_at, last_seen_at FROM tenants ORDER BY id ASC')->fetchAll();
|
||||||
jsonResponse(['users' => $rows]);
|
jsonResponse(['users' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ if ($route === '/api/users' && $method === 'POST') {
|
|||||||
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
||||||
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
||||||
}
|
}
|
||||||
if (strlen($password) < 4) jsonResponse(['error' => 'password must be at least 4 chars'], 400);
|
if (strlen($password) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
if (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
|
if (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
|
||||||
|
|
||||||
// No client secret — the new user sets that themselves after first login
|
// No client secret — the new user sets that themselves after first login
|
||||||
@@ -53,7 +53,7 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$fields = []; $params = ['id' => $id];
|
$fields = []; $params = ['id' => $id];
|
||||||
if (!empty($input['password'])) {
|
if (!empty($input['password'])) {
|
||||||
if (strlen($input['password']) < 4) jsonResponse(['error' => 'password too short'], 400);
|
if (strlen($input['password']) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
$fields[] = 'password_hash = :pw';
|
$fields[] = 'password_hash = :pw';
|
||||||
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|||||||
if ($id === (int)$sessionTenant['id']) {
|
if ($id === (int)$sessionTenant['id']) {
|
||||||
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
||||||
}
|
}
|
||||||
|
// chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
|
||||||
|
// SET NULL — that leaves sender_username populated so the deleted user
|
||||||
|
// keeps showing up as a chat thread for their peers. Purge those rows
|
||||||
|
// explicitly BEFORE the tenant delete, while sender_id is still matchable.
|
||||||
|
$db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
|
||||||
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
+394
-33
@@ -23,14 +23,16 @@ if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
|||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$myId = $sessionTenant['id'];
|
$myId = $sessionTenant['id'];
|
||||||
|
// 1:1 threads: my 1:1 messages grouped by peer username. Group sends
|
||||||
|
// (room_uid IS NOT NULL) are excluded so they don't surface here.
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
||||||
FROM chat_messages WHERE recipient_id = :r
|
FROM chat_messages WHERE recipient_id = :r AND room_uid IS NULL
|
||||||
GROUP BY sender_username
|
GROUP BY sender_username
|
||||||
UNION
|
UNION
|
||||||
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
||||||
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
||||||
WHERE cm.sender_id = :s AND cm.recipient_id != :s2
|
WHERE cm.sender_id = :s AND cm.recipient_id != :s2 AND cm.room_uid IS NULL
|
||||||
GROUP BY t.username
|
GROUP BY t.username
|
||||||
');
|
');
|
||||||
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
||||||
@@ -42,29 +44,85 @@ if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
|||||||
$threads[$peer]['count'] += (int)$row['count'];
|
$threads[$peer]['count'] += (int)$row['count'];
|
||||||
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
||||||
}
|
}
|
||||||
usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
|
|
||||||
// Add unread count per peer
|
|
||||||
foreach ($threads as &$t) {
|
foreach ($threads as &$t) {
|
||||||
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
|
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0 AND room_uid IS NULL');
|
||||||
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
||||||
$t['unread'] = (int)$stmt2->fetchColumn();
|
$t['unread'] = (int)$stmt2->fetchColumn();
|
||||||
}
|
}
|
||||||
jsonResponse(array_values($threads));
|
unset($t);
|
||||||
|
// Group rooms the caller is a member of. The UI renders these as
|
||||||
|
// first-class threads alongside 1:1 conversations; room_uid marks them
|
||||||
|
// so the client can key off the right identifier.
|
||||||
|
$roomStmt = $db->prepare('
|
||||||
|
SELECT r.uid, r.name, r.owner_username,
|
||||||
|
(SELECT MAX(created_at) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS last_at,
|
||||||
|
(SELECT COUNT(*) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS count
|
||||||
|
FROM chat_rooms r
|
||||||
|
INNER JOIN chat_room_members m ON m.room_uid = r.uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
ORDER BY last_at DESC
|
||||||
|
');
|
||||||
|
$roomStmt->execute(['tid' => $myId]);
|
||||||
|
$roomThreads = [];
|
||||||
|
foreach ($roomStmt->fetchAll() as $r) {
|
||||||
|
$roomThreads[] = [
|
||||||
|
'room_uid' => $r['uid'],
|
||||||
|
'name' => $r['name'],
|
||||||
|
'owner' => $r['owner_username'],
|
||||||
|
'last_at' => $r['last_at'] ?? '',
|
||||||
|
'count' => (int)$r['count'],
|
||||||
|
'unread' => 0, // rooms have no per-recipient seen state yet
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$combined = array_merge(array_values($threads), $roomThreads);
|
||||||
|
usort($combined, fn($a, $b) => strcmp($b['last_at'] ?? '', $a['last_at'] ?? ''));
|
||||||
|
jsonResponse($combined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
||||||
$peer = trim($_GET['peer'] ?? '');
|
$peer = trim($_GET['peer'] ?? '');
|
||||||
if (!$peer) jsonResponse(['error' => 'peer required'], 400);
|
$roomUid = trim($_GET['room_uid'] ?? '');
|
||||||
|
if ($peer === '' && $roomUid === '') jsonResponse(['error' => 'peer or room_uid required'], 400);
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$myId = $sessionTenant['id'];
|
$myId = $sessionTenant['id'];
|
||||||
|
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
// Group room: caller must be a current member.
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $myId]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_id, sender_username, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE room_uid = :r
|
||||||
|
ORDER BY created_at ASC LIMIT 500
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $roomUid]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'from' => $row['sender_username'],
|
||||||
|
'mine' => (int)$row['sender_id'] === $myId,
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'seen' => 1, // group rooms don't track per-recipient seen
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1:1 fallback
|
||||||
$peerTenant = getTenantByUsername($db, $peer);
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
|
if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404);
|
||||||
$peerId = $peerTenant['id'];
|
$peerId = $peerTenant['id'];
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT id, sender_id, sender_username, body, created_at, seen
|
SELECT id, sender_id, sender_username, body, created_at, seen
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
WHERE room_uid IS NULL
|
||||||
|
AND ((recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s))
|
||||||
ORDER BY created_at ASC LIMIT 500
|
ORDER BY created_at ASC LIMIT 500
|
||||||
');
|
');
|
||||||
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
||||||
@@ -90,13 +148,30 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$body = trim((string)($input['body'] ?? ''));
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400);
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$stored = clientEncrypt($body, $interKey);
|
$stored = clientEncrypt($body, $interKey);
|
||||||
|
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
// Group post: verify membership. `recipient_id` NOT NULL because of
|
||||||
|
// the FK, so we park the sender's own id there — GET filters on
|
||||||
|
// room_uid, never recipient_id, for group rows.
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $sessionTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)')
|
||||||
|
->execute([
|
||||||
|
'me' => $sessionTenant['id'], 's' => $sessionTenant['id'],
|
||||||
|
'sn' => $sessionTenant['username'], 'ru' => $roomUid, 'b' => $stored,
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'room_uid' => $roomUid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
||||||
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
@@ -106,16 +181,182 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
|||||||
|
|
||||||
$clientTenant = $clientTenant ?? authClient($db);
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
// Sync-event notifications. Client API only: a tenant that just synced
|
||||||
|
// a received card POSTs here to announce it to the original sender, and
|
||||||
|
// the sender polls GET to surface each event as a transient toast.
|
||||||
|
// Bodies are short plaintext strings like "alice synced: Card Title" —
|
||||||
|
// no encryption, no per-peer routing through the chat thread, no
|
||||||
|
// clutter in the regular chat history.
|
||||||
|
|
||||||
|
if ($route === '/api/sync-events' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||||
|
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
|
||||||
|
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
|
||||||
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/sync-events' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_username, body, created_at
|
||||||
|
FROM relay_sync_events
|
||||||
|
WHERE recipient_id = :r AND fetched = 0
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 100
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $clientTenant['id']]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if ($rows) {
|
||||||
|
$maxId = (int)$rows[count($rows) - 1]['id'];
|
||||||
|
$db->prepare('UPDATE relay_sync_events SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
|
||||||
|
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
||||||
|
}
|
||||||
|
$out = array_map(fn($r) => [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'sender_username' => $r['sender_username'],
|
||||||
|
'body' => $r['body'],
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
], $rows);
|
||||||
|
jsonResponse(['events' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Chat rooms (group chat) ──────────────────────────────────
|
||||||
|
// Mirrors the collab-bucket pattern: named container + member list.
|
||||||
|
// Group messages live in chat_messages with room_uid set (recipient_id
|
||||||
|
// is left 0 / NULL — fan-out happens via the join on chat_room_members
|
||||||
|
// at GET time instead of duplicating rows per recipient). Rooms are
|
||||||
|
// always persistent — message retention ignores the 7-day disposable
|
||||||
|
// window.
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['uid'] ?? ''));
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||||
|
if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400);
|
||||||
|
$exists = $db->prepare('SELECT 1 FROM chat_rooms WHERE uid = :u');
|
||||||
|
$exists->execute(['u' => $uid]);
|
||||||
|
if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409);
|
||||||
|
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($memberUsernames as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '' || $u === $clientTenant['username']) continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->beginTransaction();
|
||||||
|
$db->prepare('INSERT INTO chat_rooms (uid, owner_id, owner_username, name) VALUES (:uid, :oid, :ou, :n)')
|
||||||
|
->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name]);
|
||||||
|
$mstmt = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$mstmt->execute(['r' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
|
@error_log('[hyprcrd-relay] chat room create failed: ' . $e->getMessage());
|
||||||
|
jsonResponse(['error' => 'create failed'], 500);
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/chat/rooms' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT r.uid, r.name, r.owner_username, r.created_at
|
||||||
|
FROM chat_rooms r
|
||||||
|
INNER JOIN chat_room_members m ON m.room_uid = r.uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $clientTenant['id']]);
|
||||||
|
$rooms = $stmt->fetchAll();
|
||||||
|
$mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :uid ORDER BY username');
|
||||||
|
foreach ($rooms as &$r) {
|
||||||
|
$mstmt->execute(['uid' => $r['uid']]);
|
||||||
|
$r['members'] = array_column($mstmt->fetchAll(), 'username');
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'rooms' => $rooms]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$room = $row->fetch();
|
||||||
|
if (!$room) jsonResponse(['error' => 'room not found'], 404);
|
||||||
|
if ((int)$room['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403);
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
if (isset($input['name'])) {
|
||||||
|
$name = trim((string)$input['name']);
|
||||||
|
if ($name === '') jsonResponse(['error' => 'name cannot be empty'], 400);
|
||||||
|
$db->prepare('UPDATE chat_rooms SET name = :n WHERE uid = :u')->execute(['n' => $name, 'u' => $uid]);
|
||||||
|
}
|
||||||
|
if (isset($input['members']) && is_array($input['members'])) {
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($input['members'] as $u) {
|
||||||
|
$u = trim((string)$u);
|
||||||
|
if ($u === '') continue;
|
||||||
|
$t = getTenantByUsername($db, $u);
|
||||||
|
if ($t) $resolved[(int)$t['id']] = $t['username'];
|
||||||
|
}
|
||||||
|
// Owner always stays a member.
|
||||||
|
$resolved[(int)$clientTenant['id']] = $clientTenant['username'];
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$ins = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)');
|
||||||
|
foreach ($resolved as $tid => $uname) {
|
||||||
|
$ins->execute(['r' => $uid, 't' => $tid, 'u' => $uname]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u');
|
||||||
|
$row->execute(['u' => $uid]);
|
||||||
|
$room = $row->fetch();
|
||||||
|
if (!$room) jsonResponse(['error' => 'room not found'], 404);
|
||||||
|
|
||||||
|
if ((int)$room['owner_id'] === (int)$clientTenant['id']) {
|
||||||
|
$db->prepare('DELETE FROM chat_messages WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'deleted']);
|
||||||
|
} else {
|
||||||
|
$del = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u AND tenant_id = :t');
|
||||||
|
$del->execute(['u' => $uid, 't' => $clientTenant['id']]);
|
||||||
|
jsonResponse(['ok' => true, 'action' => 'left']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat' && $method === 'POST') {
|
if ($route === '/api/chat' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$encryptedBody = (string)($input['body'] ?? '');
|
$encryptedBody = (string)($input['body'] ?? '');
|
||||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
|
$disposable = !empty($input['disposable']);
|
||||||
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400);
|
||||||
|
if ($recipientName !== '' && $roomUid !== '') jsonResponse(['error' => 'pick either recipient or room_uid, not both'], 400);
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
||||||
|
|
||||||
if (empty($clientTenant['secret_encrypted'])) {
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
|
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
|
||||||
@@ -129,13 +370,28 @@ if ($route === '/api/chat' && $method === 'POST') {
|
|||||||
if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400);
|
if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400);
|
||||||
$stored = clientEncrypt($plain, $interKey);
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
if ($roomUid !== '') {
|
||||||
->execute([
|
// Group message: verify membership. `recipient_id` is NOT NULL +
|
||||||
'r' => $recipient['id'],
|
// has a tenants-FK, so we set it to the sender's own id as a
|
||||||
's' => $clientTenant['id'],
|
// placeholder — GET queries key off room_uid, never recipient_id,
|
||||||
'sn' => $clientTenant['username'],
|
// for group rows so this doesn't create a self-message artefact.
|
||||||
'b' => $stored,
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
]);
|
$check->execute(['r' => $roomUid, 't' => $clientTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)')
|
||||||
|
->execute(['me' => $clientTenant['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]);
|
||||||
|
} else {
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)')
|
||||||
|
->execute([
|
||||||
|
'r' => $recipient['id'],
|
||||||
|
's' => $clientTenant['id'],
|
||||||
|
'sn' => $clientTenant['username'],
|
||||||
|
'b' => $stored,
|
||||||
|
'p' => $disposable ? 0 : 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +402,24 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$since = (int)($_GET['since'] ?? 0);
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
$stmt = $db->prepare('SELECT id, sender_username, body, created_at FROM chat_messages WHERE recipient_id = :r AND id > :s ORDER BY id ASC LIMIT 500');
|
// Return 1:1 messages addressed to me + group messages for rooms I'm
|
||||||
$stmt->execute(['r' => $clientTenant['id'], 's' => $since]);
|
// in (including my own group sends, so multi-device picks them up —
|
||||||
|
// the client dedups on relay_id). Exclude my own 1:1 sends — the
|
||||||
|
// sender has them locally already and the /api/chat/sent endpoint
|
||||||
|
// covers cross-device state for 1:1.
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_id, sender_username, recipient_id, room_uid, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE id > :s
|
||||||
|
AND (
|
||||||
|
(recipient_id = :me AND room_uid IS NULL)
|
||||||
|
OR (room_uid IS NOT NULL AND room_uid IN (
|
||||||
|
SELECT room_uid FROM chat_room_members WHERE tenant_id = :me2
|
||||||
|
))
|
||||||
|
)
|
||||||
|
ORDER BY id ASC LIMIT 500
|
||||||
|
');
|
||||||
|
$stmt->execute(['me' => $clientTenant['id'], 'me2' => $clientTenant['id'], 's' => $since]);
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
@@ -159,20 +431,106 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
$maxId = $since;
|
$maxId = $since;
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
||||||
$row['body'] = clientEncrypt($plain, $recipientSecret);
|
$out[] = [
|
||||||
$out[] = $row;
|
'id' => (int)$row['id'],
|
||||||
|
'sender_id' => (int)$row['sender_id'],
|
||||||
|
'sender_username' => $row['sender_username'],
|
||||||
|
'room_uid' => $row['room_uid'],
|
||||||
|
'body' => clientEncrypt($plain, $recipientSecret),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'],
|
||||||
|
];
|
||||||
if ((int)$row['id'] > $maxId) $maxId = (int)$row['id'];
|
if ((int)$row['id'] > $maxId) $maxId = (int)$row['id'];
|
||||||
}
|
}
|
||||||
// Mark fetched so cleanup can prune them sooner if desired. We still
|
// Mark fetched on 1:1 messages so disposable ones can be pruned
|
||||||
// keep them around for the 7-day window so a re-poll with since=0
|
// sooner. Group messages stay persistent regardless — no per-poll
|
||||||
// backfills history if a client wipes its local cache.
|
// fan-out state, so nothing to flag there.
|
||||||
if ($out) {
|
if ($out) {
|
||||||
$db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0')
|
$db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND room_uid IS NULL AND id <= :m AND fetched = 0')
|
||||||
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
->execute(['r' => $clientTenant['id'], 'm' => $maxId]);
|
||||||
}
|
}
|
||||||
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
jsonResponse(['messages' => $out, 'max_id' => $maxId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /api/chat/backfill ─────────────────────────────────────
|
||||||
|
// Scrollback / cold-start backfill for a single thread. Pulls older
|
||||||
|
// messages before a given id, re-encrypted for the caller. Used when
|
||||||
|
// the client opens a room it's never seen, or when the local cache
|
||||||
|
// was wiped on a second device.
|
||||||
|
// ?room_uid=X — group scrollback (membership checked)
|
||||||
|
// ?peer=X — 1:1 scrollback with that peer
|
||||||
|
// &before=N — only messages with id < N (default: latest)
|
||||||
|
// &limit=N — page size (default 100, max 500)
|
||||||
|
|
||||||
|
if ($route === '/api/chat/backfill' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$roomUid = trim((string)($_GET['room_uid'] ?? ''));
|
||||||
|
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||||
|
$before = (int)($_GET['before'] ?? 0);
|
||||||
|
$limit = max(1, min(500, (int)($_GET['limit'] ?? 100)));
|
||||||
|
if ($roomUid === '' && $peer === '') jsonResponse(['error' => 'room_uid or peer required'], 400);
|
||||||
|
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||||
|
|
||||||
|
$beforeClause = $before > 0 ? 'AND id < :before' : '';
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $clientTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
||||||
|
FROM chat_messages WHERE room_uid = :ru $beforeClause
|
||||||
|
ORDER BY id DESC LIMIT :lim";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->bindValue(':ru', $roomUid);
|
||||||
|
if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
|
if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404);
|
||||||
|
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE room_uid IS NULL
|
||||||
|
AND (
|
||||||
|
(recipient_id = :me AND sender_id = :peerid)
|
||||||
|
OR (recipient_id = :peerid2 AND sender_id = :me2)
|
||||||
|
)
|
||||||
|
$beforeClause
|
||||||
|
ORDER BY id DESC LIMIT :lim";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->bindValue(':me', $clientTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':me2', $clientTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':peerid', $peerTenant['id'], PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':peerid2', $peerTenant['id'], PDO::PARAM_INT);
|
||||||
|
if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
// Flip back to ASC for a natural render order on the client.
|
||||||
|
$rows = array_reverse($rows);
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$plain = clientDecrypt($row['body'] ?? '', $interKey);
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'sender_id' => (int)$row['sender_id'],
|
||||||
|
'sender_username' => $row['sender_username'],
|
||||||
|
'room_uid' => $row['room_uid'],
|
||||||
|
'body' => clientEncrypt($plain, $recipientSecret),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['messages' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /api/chat/sent ─────────────────────────────────────────
|
// ── GET /api/chat/sent ─────────────────────────────────────────
|
||||||
// Returns the receipt state (fetched/seen) for messages the caller has
|
// Returns the receipt state (fetched/seen) for messages the caller has
|
||||||
// sent. Body is intentionally NOT included — the sender already has the
|
// sent. Body is intentionally NOT included — the sender already has the
|
||||||
@@ -182,10 +540,13 @@ if ($route === '/api/chat' && $method === 'GET') {
|
|||||||
if ($route === '/api/chat/sent' && $method === 'GET') {
|
if ($route === '/api/chat/sent' && $method === 'GET') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$since = (int)($_GET['since'] ?? 0);
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
|
// 1:1 only — group messages use the same sender_id but have no
|
||||||
|
// per-recipient receipt state, so including them would stall the
|
||||||
|
// cursor on rows that never reach seen=1.
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT id, recipient_id, fetched, seen, created_at
|
SELECT id, recipient_id, fetched, seen, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE sender_id = :s AND id > :since
|
WHERE sender_id = :s AND room_uid IS NULL AND id > :since
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
');
|
');
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ if ($route === '/api/collab-buckets' && $method === 'POST') {
|
|||||||
$db->commit();
|
$db->commit();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
|
@error_log('[hyprcrd-relay] collab bucket create failed: ' . $e->getMessage());
|
||||||
|
jsonResponse(['error' => 'create failed'], 500);
|
||||||
}
|
}
|
||||||
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $metho
|
|||||||
$newOwnerName = trim((string)$input['owner']);
|
$newOwnerName = trim((string)$input['owner']);
|
||||||
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
|
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
|
||||||
$newOwner = getTenantByUsername($db, $newOwnerName);
|
$newOwner = getTenantByUsername($db, $newOwnerName);
|
||||||
if (!$newOwner) jsonResponse(['error' => "no such user: $newOwnerName"], 404);
|
if (!$newOwner) jsonResponse(['error' => 'user not found'], 404);
|
||||||
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
||||||
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
|
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
|
||||||
if (!$memCheck->fetchColumn()) {
|
if (!$memCheck->fetchColumn()) {
|
||||||
|
|||||||
@@ -105,6 +105,47 @@ if ($route === '/api/collab-cards' && $method === 'POST') {
|
|||||||
|
|
||||||
// ── GET: pull deltas since a given timestamp ─────────────────────
|
// ── GET: pull deltas since a given timestamp ─────────────────────
|
||||||
|
|
||||||
|
// ── Session-auth summary for the relay UI ─────────────────────
|
||||||
|
// Lets the relay's web UI surface collab bucket activity to the
|
||||||
|
// logged-in tenant without exposing the per-client encryption dance:
|
||||||
|
// decrypts payloads with the inter-tenant key (we're authorizing via
|
||||||
|
// the PHP session, so there's no per-request client secret). Returns
|
||||||
|
// just title + small body preview + author + bucket so the UI can list
|
||||||
|
// recent activity like an inbox.
|
||||||
|
if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted,
|
||||||
|
b.name AS bucket_name, b.color AS bucket_color
|
||||||
|
FROM collab_cards c
|
||||||
|
INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid
|
||||||
|
LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid
|
||||||
|
WHERE m.tenant_id = :tid AND c.deleted = 0
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
');
|
||||||
|
$stmt->execute(['tid' => $sessionTenant['id']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$plain = clientDecrypt($row['payload'] ?? '', $interKey);
|
||||||
|
$fields = $plain ? (json_decode($plain, true) ?: []) : [];
|
||||||
|
$body = (string)($fields['body'] ?? '');
|
||||||
|
$out[] = [
|
||||||
|
'bucket_uid' => $row['bucket_uid'],
|
||||||
|
'bucket_name' => $row['bucket_name'],
|
||||||
|
'bucket_color' => $row['bucket_color'] ?? '#38bdf8',
|
||||||
|
'card_uid' => $row['card_uid'],
|
||||||
|
'author_username' => $row['author_username'],
|
||||||
|
'title' => (string)($fields['title'] ?? ''),
|
||||||
|
'preview' => mb_substr(trim(strip_tags($body)), 0, 140),
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
'mine' => $row['author_username'] === $sessionTenant['username'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'items' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/collab-cards' && $method === 'GET') {
|
if ($route === '/api/collab-cards' && $method === 'GET') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
@@ -199,3 +240,76 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
|
|||||||
]);
|
]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
||||||
|
|
||||||
|
// Attachment filenames always follow the hyprcrd upload pattern
|
||||||
|
// (bin2hex(random_bytes(16)).<ext>). Enforce server-side so malicious
|
||||||
|
// clients can't poison the DB with traversal-shaped names that later
|
||||||
|
// surface in UIs or get forwarded to filesystem APIs.
|
||||||
|
function collabAttachmentFilenameValid(string $fn): bool {
|
||||||
|
return (bool)preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketUid = (string)($input['bucket_uid'] ?? '');
|
||||||
|
$filename = (string)($input['filename'] ?? '');
|
||||||
|
$data = (string)($input['data'] ?? '');
|
||||||
|
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
|
||||||
|
if (!collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt-reencrypt hop: data arrives encrypted with sender's secret,
|
||||||
|
// stored at rest under the inter-tenant key.
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'sender has no client secret'], 400);
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($data, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO collab_attachments (bucket_uid, filename, data) VALUES (:b, :f, :d)
|
||||||
|
ON CONFLICT(bucket_uid, filename) DO UPDATE SET data = excluded.data')
|
||||||
|
->execute(['b' => $bucketUid, 'f' => $filename, 'd' => $stored]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$bucketUid = trim($_GET['bucket_uid'] ?? '');
|
||||||
|
$filename = trim($_GET['filename'] ?? '');
|
||||||
|
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
|
||||||
|
if ($filename !== '' && !collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'no client secret set'], 400);
|
||||||
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
if ($filename) {
|
||||||
|
$stmt = $db->prepare('SELECT filename, data FROM collab_attachments WHERE bucket_uid = :b AND filename = :f');
|
||||||
|
$stmt->execute(['b' => $bucketUid, 'f' => $filename]);
|
||||||
|
} else {
|
||||||
|
// List all filenames for this bucket (no data — client picks what to fetch)
|
||||||
|
$stmt = $db->prepare('SELECT filename FROM collab_attachments WHERE bucket_uid = :b ORDER BY created_at DESC');
|
||||||
|
$stmt->execute(['b' => $bucketUid]);
|
||||||
|
jsonResponse(['ok' => true, 'files' => array_column($stmt->fetchAll(), 'filename')]);
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$plain = clientDecrypt($row['data'] ?? '', $interKey);
|
||||||
|
$out[] = ['filename' => $row['filename'], 'data' => clientEncrypt($plain, $recipientSecret)];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'attachments' => $out]);
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ function collabLocksIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
|
|||||||
|
|
||||||
// ── POST: acquire/refresh ────────────────────────────────────────
|
// ── POST: acquire/refresh ────────────────────────────────────────
|
||||||
|
|
||||||
|
// Maximum time a single holder can keep refreshing the same lock. Without a
|
||||||
|
// cap a malicious member could heartbeat forever, blocking every other
|
||||||
|
// member's writes. 10 minutes is long enough for any real edit session and
|
||||||
|
// short enough to time-box grief. When the cap is hit the lock is force-
|
||||||
|
// released and the client must re-acquire — giving everyone else a window.
|
||||||
|
const COLLAB_LOCK_MAX_HOLD_SECONDS = 600;
|
||||||
|
|
||||||
if ($route === '/api/collab-locks' && $method === 'POST') {
|
if ($route === '/api/collab-locks' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
@@ -35,8 +42,12 @@ if ($route === '/api/collab-locks' && $method === 'POST') {
|
|||||||
// narrow window between cleanup and this query.
|
// narrow window between cleanup and this query.
|
||||||
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')")
|
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')")
|
||||||
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
|
// Also drop locks that have been refreshed past the max hold window —
|
||||||
|
// cap enforcement for grief prevention.
|
||||||
|
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND acquired_at <= datetime('now', '-" . COLLAB_LOCK_MAX_HOLD_SECONDS . " seconds')")
|
||||||
|
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
// Existing live lock? If a different tenant holds it, refuse.
|
// Existing live lock? If a different tenant holds it, refuse.
|
||||||
$cur = $db->prepare('SELECT holder_id, holder_username, expires_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c');
|
$cur = $db->prepare('SELECT holder_id, holder_username, expires_at, acquired_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c');
|
||||||
$cur->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
$cur->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
$existing = $cur->fetch();
|
$existing = $cur->fetch();
|
||||||
if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) {
|
if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
@@ -54,7 +54,7 @@ if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant
|
|||||||
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
@@ -142,7 +142,7 @@ if ($route === '/api/messages' && $method === 'POST') {
|
|||||||
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
// Decrypt with sender's secret
|
// Decrypt with sender's secret
|
||||||
if (empty($clientTenant['secret_encrypted'])) {
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
@@ -225,9 +225,12 @@ if ($route === '/api/attachments' && $method === 'POST') {
|
|||||||
$data = (string)($input['data'] ?? '');
|
$data = (string)($input['data'] ?? '');
|
||||||
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
||||||
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
|
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
|
||||||
|
// Enforce the hyprcrd upload naming scheme server-side so traversal-shaped
|
||||||
|
// names never enter storage or get forwarded back to other clients.
|
||||||
|
if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
// Decrypt-reencrypt hop, same shape as messages: arrives encrypted with
|
// Decrypt-reencrypt hop, same shape as messages: arrives encrypted with
|
||||||
// sender's secret, gets stored at-rest under the inter-tenant key.
|
// sender's secret, gets stored at-rest under the inter-tenant key.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ if ($route === '/api/setup' && $method === 'POST') {
|
|||||||
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
||||||
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
||||||
}
|
}
|
||||||
if (strlen($password) < 4) jsonResponse(['error' => 'password must be at least 4 chars'], 400);
|
if (strlen($password) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
if (strlen($superSecret) < 12) jsonResponse(['error' => 'super-shared secret must be at least 12 chars'], 400);
|
if (strlen($superSecret) < 12) jsonResponse(['error' => 'super-shared secret must be at least 12 chars'], 400);
|
||||||
|
|
||||||
// Create the admin tenant. NO client secret yet — the admin (and every
|
// Create the admin tenant. NO client secret yet — the admin (and every
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
// Read-later routes:
|
||||||
|
// POST /api/read (client API: upsert an item)
|
||||||
|
// DELETE /api/read/{uid} (client or session: remove)
|
||||||
|
// GET /api/read (session: list own items)
|
||||||
|
// GET /api/read/{uid} (session: one item, decrypted)
|
||||||
|
// POST /api/read-attachments (client API: upload image blob)
|
||||||
|
// GET /api/read-attachments/{uid}/{fn} (session: serve decrypted bytes)
|
||||||
|
//
|
||||||
|
// Encryption mirrors messages / collab-cards: client encrypts with their
|
||||||
|
// own secret, relay decrypts with senderSecret, re-encrypts under the
|
||||||
|
// inter-tenant key for at-rest storage, decrypts again for the session
|
||||||
|
// UI. Hard delete — no tombstones (no offline-sync requirement, the
|
||||||
|
// hyprcrd client is the source of truth).
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
// Session tenant is set by routes/public.php earlier in the chain;
|
||||||
|
// client-API auth is lazy so we don't hit authClient() when the session
|
||||||
|
// alone suffices.
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
// ── Client API: upsert an item ────────────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'sender has no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$relayUid = trim((string)($input['relay_uid'] ?? ''));
|
||||||
|
$encTitle = (string)($input['title'] ?? '');
|
||||||
|
$encBody = (string)($input['body'] ?? '');
|
||||||
|
$heroFilename = trim((string)($input['hero_filename'] ?? ''));
|
||||||
|
if ($relayUid === '' || $encBody === '') {
|
||||||
|
jsonResponse(['error' => 'relay_uid and body required'], 400);
|
||||||
|
}
|
||||||
|
// Hero filename follows the hyprcrd upload naming scheme; empty is
|
||||||
|
// valid (no inline images).
|
||||||
|
if ($heroFilename !== '' && !preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $heroFilename)) {
|
||||||
|
jsonResponse(['error' => 'invalid hero_filename'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plainTitle = clientDecrypt($encTitle, $senderSecret);
|
||||||
|
$plainBody = clientDecrypt($encBody, $senderSecret);
|
||||||
|
if ($plainBody === '') jsonResponse(['error' => 'body decrypt failed'], 400);
|
||||||
|
|
||||||
|
$storedTitle = clientEncrypt($plainTitle, $interKey);
|
||||||
|
$storedBody = clientEncrypt($plainBody, $interKey);
|
||||||
|
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO read_items (tenant_id, relay_uid, title, body, hero_filename, updated_at)
|
||||||
|
VALUES (:t, :u, :ti, :bo, :hf, datetime('now'))
|
||||||
|
ON CONFLICT(tenant_id, relay_uid) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
body = excluded.body,
|
||||||
|
hero_filename = excluded.hero_filename,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
")->execute([
|
||||||
|
't' => $clientTenant['id'],
|
||||||
|
'u' => $relayUid,
|
||||||
|
'ti' => $storedTitle,
|
||||||
|
'bo' => $storedBody,
|
||||||
|
'hf' => $heroFilename !== '' ? $heroFilename : null,
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true, 'relay_uid' => $relayUid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/read/{uid} ────────────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$tenant = $clientTenant ?: $sessionTenant;
|
||||||
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$db->prepare('DELETE FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u')
|
||||||
|
->execute(['t' => $tenant['id'], 'u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM read_items WHERE tenant_id = :t AND relay_uid = :u')
|
||||||
|
->execute(['t' => $tenant['id'], 'u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: list own items (for the relay UI) ────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT relay_uid, title, hero_filename, created_at, updated_at, length(body) AS body_len
|
||||||
|
FROM read_items WHERE tenant_id = :t
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'relay_uid' => $row['relay_uid'],
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'hero_filename' => $row['hero_filename'],
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['items' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: one item with body ───────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'GET' && $sessionTenant) {
|
||||||
|
$uid = $m[1];
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('SELECT relay_uid, title, body, hero_filename, created_at, updated_at FROM read_items WHERE tenant_id = :t AND relay_uid = :u');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) jsonResponse(['error' => 'not found'], 404);
|
||||||
|
jsonResponse([
|
||||||
|
'relay_uid' => $row['relay_uid'],
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'hero_filename' => $row['hero_filename'],
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client API: upload inline image blob ──────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read-attachments' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'sender has no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$relayUid = (string)($input['relay_uid'] ?? '');
|
||||||
|
$filename = (string)($input['filename'] ?? '');
|
||||||
|
$data = (string)($input['data'] ?? '');
|
||||||
|
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, data required'], 400);
|
||||||
|
if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($data, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO read_attachments (tenant_id, relay_uid, filename, data) VALUES (:t, :u, :f, :d)
|
||||||
|
ON CONFLICT(tenant_id, relay_uid, filename) DO UPDATE SET data = excluded.data')
|
||||||
|
->execute(['t' => $clientTenant['id'], 'u' => $relayUid, 'f' => $filename, 'd' => $stored]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: serve decrypted image bytes ──────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read-attachments/([a-zA-Z0-9_-]+)/([a-f0-9]+\.[a-z0-9]{1,8})$#', $route, $m) && $method === 'GET' && $sessionTenant) {
|
||||||
|
$uid = $m[1];
|
||||||
|
$filename = $m[2];
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) { http_response_code(500); exit; }
|
||||||
|
$stmt = $db->prepare('SELECT data FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u AND filename = :f');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid, 'f' => $filename]);
|
||||||
|
$enc = $stmt->fetchColumn();
|
||||||
|
if (!$enc) { http_response_code(404); exit; }
|
||||||
|
$b64 = clientDecrypt($enc, $interKey);
|
||||||
|
$bytes = base64_decode($b64, true);
|
||||||
|
if ($bytes === false) { http_response_code(500); exit; }
|
||||||
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
$mimes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'];
|
||||||
|
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
echo $bytes;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
// Shopping-list routes:
|
||||||
|
// POST /api/shopping (client) upsert one list {card_uid, title, body}
|
||||||
|
// GET /api/shopping (client + session) all active lists for tenant
|
||||||
|
// POST /api/shopping/check (session) toggle one line {card_uid, line, checked}
|
||||||
|
// DELETE /api/shopping (client) remove a deactivated list {card_uid}
|
||||||
|
//
|
||||||
|
// Multiple lists per tenant (composite PK: tenant_id + card_uid).
|
||||||
|
// Plaintext on purpose — shopping-list content is low-stakes.
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
$tenant = $sessionTenant ?: $clientTenant;
|
||||||
|
|
||||||
|
function shoppingLoadAll(PDO $db, int $tenantId): array {
|
||||||
|
// Exclude __inactive__ sentinel rows left over from the old
|
||||||
|
// single-list era (clearRemote used to write a dummy row instead
|
||||||
|
// of deleting).
|
||||||
|
$stmt = $db->prepare("SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid != '__inactive__' ORDER BY position, title");
|
||||||
|
$stmt->execute(['t' => $tenantId]);
|
||||||
|
return $stmt->fetchAll() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array {
|
||||||
|
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u');
|
||||||
|
$stmt->execute(['t' => $tenantId, 'u' => $cardUid]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/shopping ────────────────────────────────────────
|
||||||
|
if ($route === '/api/shopping' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||||
|
$title = (string)($input['title'] ?? '');
|
||||||
|
$body = (string)($input['body'] ?? '');
|
||||||
|
$pos = (int)($input['position'] ?? 0);
|
||||||
|
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
|
||||||
|
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
||||||
|
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
$db->prepare('
|
||||||
|
INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server)
|
||||||
|
VALUES (:t, :u, :ti, :b, :p, :c, :s)
|
||||||
|
ON CONFLICT(tenant_id, card_uid) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
body = excluded.body,
|
||||||
|
position = excluded.position,
|
||||||
|
updated_at_client = excluded.updated_at_client,
|
||||||
|
updated_at_server = excluded.updated_at_server
|
||||||
|
')->execute([
|
||||||
|
't' => $clientTenant['id'],
|
||||||
|
'u' => $uid,
|
||||||
|
'ti' => $title,
|
||||||
|
'b' => $body,
|
||||||
|
'p' => $pos,
|
||||||
|
'c' => $clientTs,
|
||||||
|
's' => $now,
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/shopping ─────────────────────────────────────
|
||||||
|
// Two modes:
|
||||||
|
// { card_uid: "123" } → delete one specific list
|
||||||
|
// { keep: ["123", "456"] } → delete everything EXCEPT these
|
||||||
|
if ($route === '/api/shopping' && $method === 'DELETE') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$keep = is_array($input['keep'] ?? null) ? $input['keep'] : null;
|
||||||
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||||
|
|
||||||
|
if ($keep !== null) {
|
||||||
|
// Bulk cleanup: keep only the listed card_uids, drop the rest.
|
||||||
|
if (count($keep)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($keep), '?'));
|
||||||
|
$params = array_merge([$clientTenant['id']], array_map('strval', $keep));
|
||||||
|
$db->prepare("DELETE FROM shopping_state WHERE tenant_id = ? AND card_uid NOT IN ($placeholders)")
|
||||||
|
->execute($params);
|
||||||
|
} else {
|
||||||
|
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t')
|
||||||
|
->execute(['t' => $clientTenant['id']]);
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uid === '') jsonResponse(['error' => 'card_uid or keep[] required'], 400);
|
||||||
|
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
|
||||||
|
->execute(['t' => $clientTenant['id'], 'u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/shopping ────────────────────────────────────────
|
||||||
|
if ($route === '/api/shopping' && $method === 'GET') {
|
||||||
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$lists = shoppingLoadAll($db, (int)$tenant['id']);
|
||||||
|
jsonResponse(['ok' => true, 'lists' => $lists]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/shopping/check ─────────────────────────────────
|
||||||
|
if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||||
|
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||||
|
$line = (string)($input['line'] ?? '');
|
||||||
|
$checked = !empty($input['checked']);
|
||||||
|
if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
|
||||||
|
|
||||||
|
$state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid);
|
||||||
|
if (!$state) jsonResponse(['error' => 'list not found'], 404);
|
||||||
|
$body = $state['body'];
|
||||||
|
$lines = preg_split("/\r\n|\n|\r/", $body);
|
||||||
|
$hit = false;
|
||||||
|
foreach ($lines as $i => $l) {
|
||||||
|
if ($l !== $line) continue;
|
||||||
|
$toggled = $checked
|
||||||
|
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
|
||||||
|
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
|
||||||
|
if ($toggled === $l) continue;
|
||||||
|
$lines[$i] = $toggled;
|
||||||
|
$hit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
|
||||||
|
|
||||||
|
$newBody = implode("\n", $lines);
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t AND card_uid = :u')
|
||||||
|
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
|
||||||
|
}
|
||||||
+27
-3
@@ -40,10 +40,25 @@ $isSetup = tenantCount($db) === 0;
|
|||||||
|
|
||||||
if (str_starts_with($route, '/api/')) {
|
if (str_starts_with($route, '/api/')) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
// CORS: the hyprcrd client hits us server-side (no browser CORS in play)
|
||||||
|
// and the relay UI lives same-origin, so the only legitimate cross-origin
|
||||||
|
// callers are other tenant UIs on the same relay host. We allow the
|
||||||
|
// relay's own scheme+host, plus an opt-in allowlist via RELAY_CORS_ORIGINS
|
||||||
|
// (comma-separated). Credentials are intentionally NOT allowed — API auth
|
||||||
|
// is header-based (X-Relay-User/Key) and the UI is same-origin.
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
if ($origin) {
|
$ownScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' ? 'https' : 'http';
|
||||||
|
$ownOrigin = $ownScheme . '://' . ($_SERVER['HTTP_HOST'] ?? '');
|
||||||
|
$allowed = [$ownOrigin];
|
||||||
|
$extra = getenv('RELAY_CORS_ORIGINS');
|
||||||
|
if ($extra) {
|
||||||
|
foreach (explode(',', $extra) as $o) {
|
||||||
|
$o = trim($o);
|
||||||
|
if ($o !== '') $allowed[] = $o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($origin && in_array($origin, $allowed, true)) {
|
||||||
header('Access-Control-Allow-Origin: ' . $origin);
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
header('Access-Control-Allow-Credentials: true');
|
|
||||||
header('Vary: Origin');
|
header('Vary: Origin');
|
||||||
}
|
}
|
||||||
header('Access-Control-Allow-Headers: Content-Type, X-Relay-User, X-Relay-Key');
|
header('Access-Control-Allow-Headers: Content-Type, X-Relay-User, X-Relay-Key');
|
||||||
@@ -51,7 +66,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($method === 'OPTIONS') exit;
|
if ($method === 'OPTIONS') exit;
|
||||||
|
|
||||||
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
$sizeLimit = ($route === '/api/attachments') ? 15728640 : 262144;
|
// Attachment routes handle binary blobs; everything else is JSON metadata.
|
||||||
|
// A ~150KB image becomes a ~275KB envelope after base64 + GCM framing,
|
||||||
|
// so 256KB would silently drop any non-trivial image on collab pushes.
|
||||||
|
$isAttachmentRoute = in_array($route, ['/api/attachments', '/api/attachments/upload', '/api/collab-attachments', '/api/read-attachments'], true);
|
||||||
|
// Read items carry the full card body (markdown), which can be much
|
||||||
|
// longer than a chat message — raise the JSON-metadata cap for the
|
||||||
|
// read endpoint too.
|
||||||
|
$sizeLimit = $isAttachmentRoute ? 15728640 : ($route === '/api/read' ? 2097152 : 262144);
|
||||||
if ($contentLength > $sizeLimit) jsonResponse(['error' => 'payload too large'], 413);
|
if ($contentLength > $sizeLimit) jsonResponse(['error' => 'payload too large'], 413);
|
||||||
|
|
||||||
// Each route file contains its handlers as if-blocks that exit on
|
// Each route file contains its handlers as if-blocks that exit on
|
||||||
@@ -65,6 +87,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
require __DIR__ . '/inc/routes/collab-buckets.php';
|
require __DIR__ . '/inc/routes/collab-buckets.php';
|
||||||
require __DIR__ . '/inc/routes/collab-cards.php';
|
require __DIR__ . '/inc/routes/collab-cards.php';
|
||||||
require __DIR__ . '/inc/routes/collab-locks.php';
|
require __DIR__ . '/inc/routes/collab-locks.php';
|
||||||
|
require __DIR__ . '/inc/routes/shopping.php';
|
||||||
|
require __DIR__ . '/inc/routes/read.php';
|
||||||
|
|
||||||
jsonResponse(['error' => 'not found'], 404);
|
jsonResponse(['error' => 'not found'], 404);
|
||||||
}
|
}
|
||||||
|
|||||||
+474
-51
@@ -68,15 +68,18 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
name = 'change-password';
|
||||||
}
|
}
|
||||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
$('view-' + name)?.classList.remove('hidden');
|
$('view-' + name)?.classList.remove('hidden');
|
||||||
|
|
||||||
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
|
$('nav-inbox')?.classList.toggle('active', name === 'inbox');
|
||||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||||
|
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
||||||
|
$('nav-read')?.classList.toggle('active', name === 'read');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||||
$('nav-burger')?.classList.remove('hidden');
|
$('nav-burger')?.classList.remove('hidden');
|
||||||
@@ -91,12 +94,19 @@ function showView(name) {
|
|||||||
|
|
||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
|
$('compose-body')?.focus();
|
||||||
|
}
|
||||||
|
if (name === 'inbox') {
|
||||||
loadInbox();
|
loadInbox();
|
||||||
loadOutbox();
|
loadOutbox();
|
||||||
|
loadCollabInbox();
|
||||||
startInboxPoll();
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
|
||||||
} else {
|
} else {
|
||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
|
// Keep the header badge count fresh even outside the inbox
|
||||||
|
// view — a quiet load updates the count without rendering the
|
||||||
|
// full list (the DOM elements are in the hidden inbox view).
|
||||||
|
loadInbox({ quiet: true });
|
||||||
}
|
}
|
||||||
if (name === 'chat') {
|
if (name === 'chat') {
|
||||||
loadChatThreads();
|
loadChatThreads();
|
||||||
@@ -104,6 +114,22 @@ function showView(name) {
|
|||||||
} else {
|
} else {
|
||||||
stopChatPoll();
|
stopChatPoll();
|
||||||
}
|
}
|
||||||
|
if (name === 'shopping') {
|
||||||
|
loadShopping();
|
||||||
|
startShoppingPoll();
|
||||||
|
} else {
|
||||||
|
stopShoppingPoll();
|
||||||
|
}
|
||||||
|
if (name === 'read') {
|
||||||
|
loadReadList();
|
||||||
|
// Close any open article when switching into the list view.
|
||||||
|
closeReader();
|
||||||
|
}
|
||||||
|
// Remember the active view so a page reload lands back here
|
||||||
|
// instead of defaulting to compose every time.
|
||||||
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(name)) {
|
||||||
|
try { localStorage.setItem('relay-last-view', name); } catch {}
|
||||||
|
}
|
||||||
if (name === 'admin') {
|
if (name === 'admin') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (name === 'account') {
|
} else if (name === 'account') {
|
||||||
@@ -126,7 +152,11 @@ function startInboxPoll() {
|
|||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
inboxPollHandle = setInterval(() => {
|
inboxPollHandle = setInterval(() => {
|
||||||
if (inboxBusy) return;
|
if (inboxBusy) return;
|
||||||
if (!$('view-compose')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); }
|
if (!$('view-inbox')?.classList.contains('hidden')) {
|
||||||
|
loadInbox({ quiet: true });
|
||||||
|
loadOutbox({ quiet: true });
|
||||||
|
loadCollabInbox({ quiet: true });
|
||||||
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
}
|
}
|
||||||
function stopInboxPoll() {
|
function stopInboxPoll() {
|
||||||
@@ -156,21 +186,7 @@ window.toggleNavMenu = toggleNavMenu;
|
|||||||
* if at all (the inbox is already on screen). */
|
* if at all (the inbox is already on screen). */
|
||||||
function jumpToInbox() {
|
function jumpToInbox() {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
if ($('view-compose')?.classList.contains('hidden')) {
|
showView('inbox');
|
||||||
showView('compose');
|
|
||||||
}
|
|
||||||
// Wait one frame so the (possibly just-shown) view has measurable
|
|
||||||
// dimensions before scrollIntoView fires
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const target = $('inbox-section');
|
|
||||||
if (!target) return;
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
target.classList.remove('flash-highlight');
|
|
||||||
// Restart the animation by toggling the class
|
|
||||||
void target.offsetWidth;
|
|
||||||
target.classList.add('flash-highlight');
|
|
||||||
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
window.jumpToInbox = jumpToInbox;
|
window.jumpToInbox = jumpToInbox;
|
||||||
// Tap outside the menu closes it
|
// Tap outside the menu closes it
|
||||||
@@ -189,7 +205,7 @@ async function doChangePassword() {
|
|||||||
const err = $('cp-err');
|
const err = $('cp-err');
|
||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
||||||
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
|
if (newPw.length < 10) { err.textContent = 'new password must be at least 10 chars'; return; }
|
||||||
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
||||||
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
||||||
|
|
||||||
@@ -221,7 +237,7 @@ async function doSetup() {
|
|||||||
const err = $('setup-err');
|
const err = $('setup-err');
|
||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
if (!username) { err.textContent = 'username required'; return; }
|
if (!username) { err.textContent = 'username required'; return; }
|
||||||
if (password.length < 4) { err.textContent = 'password too short'; return; }
|
if (password.length < 10) { err.textContent = 'password must be at least 10 chars'; return; }
|
||||||
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
||||||
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -267,7 +283,8 @@ function routePostLogin(status) {
|
|||||||
showView('account');
|
showView('account');
|
||||||
toast('set your client secret to start using the relay');
|
toast('set your client secret to start using the relay');
|
||||||
} else {
|
} else {
|
||||||
showView('compose');
|
const last = localStorage.getItem('relay-last-view') || 'compose';
|
||||||
|
showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(last) ? last : 'compose');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +611,41 @@ async function loadOutbox({ quiet = false } = {}) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared buckets / collab inbox ──
|
||||||
|
let lastCollabJson = '';
|
||||||
|
async function loadCollabInbox({ quiet = false } = {}) {
|
||||||
|
const list = $('shared-list');
|
||||||
|
const countEl = $('shared-count');
|
||||||
|
if (!list) return;
|
||||||
|
const res = await api('api/collab-inbox');
|
||||||
|
const items = res?.items || [];
|
||||||
|
const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at));
|
||||||
|
if (quiet && json === lastCollabJson) return;
|
||||||
|
lastCollabJson = json;
|
||||||
|
if (countEl) countEl.textContent = items.length;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="ph ph-users-three icon"></i>
|
||||||
|
<div class="label">no shared bucket activity</div>
|
||||||
|
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(c => `
|
||||||
|
<div class="inbox-item">
|
||||||
|
<div class="content">
|
||||||
|
<div class="from">
|
||||||
|
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
|
||||||
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteMessage(id, btn, source) {
|
async function deleteMessage(id, btn, source) {
|
||||||
inboxBusy = true;
|
inboxBusy = true;
|
||||||
// Inline confirmation: replace the trash button with yes/no.
|
// Inline confirmation: replace the trash button with yes/no.
|
||||||
@@ -654,62 +706,239 @@ window.deleteAllMessages = deleteAllMessages;
|
|||||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||||
* or a full date for older messages. */
|
* or a full date for older messages. */
|
||||||
// ── Chat ──
|
// ── Shopping (phone-side of the hyprcrd → relay list) ──
|
||||||
|
//
|
||||||
|
// We don't own the content — hyprcrd pushes the active card's body
|
||||||
|
// to /api/shopping and we just render the unchecked "- [ ]" lines
|
||||||
|
// as tappable rows. A tap POSTs to /api/shopping/check which flips
|
||||||
|
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
|
||||||
|
|
||||||
let chatActivePeer = null;
|
let shoppingPollHandle = null;
|
||||||
|
let shoppingBusy = false;
|
||||||
|
|
||||||
|
function startShoppingPoll() {
|
||||||
|
stopShoppingPoll();
|
||||||
|
// 4 s is responsive enough that edits on the desktop show up
|
||||||
|
// while you're still holding the phone, and cheap enough that
|
||||||
|
// an always-on tab costs basically nothing (single indexed
|
||||||
|
// SELECT per poll).
|
||||||
|
shoppingPollHandle = setInterval(() => {
|
||||||
|
if (!document.hidden && !shoppingBusy) loadShopping(true);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
function stopShoppingPoll() {
|
||||||
|
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function shoppingErr(msg) {
|
||||||
|
const el = $('shopping-err');
|
||||||
|
if (!el) return;
|
||||||
|
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shoppingParseLines(body) {
|
||||||
|
// Two node kinds end up on the phone:
|
||||||
|
// heading — "# h1" … "###### h6" inside the body, used to
|
||||||
|
// section the list (Obst / Backwaren / …)
|
||||||
|
// item — unchecked "- [ ] …" lines (checked ones are hidden)
|
||||||
|
// Then drop orphan headings (no open items before the next
|
||||||
|
// heading or end of list) so empty section labels don't sit
|
||||||
|
// around after everything under them is ticked off.
|
||||||
|
const nodes = [];
|
||||||
|
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
|
||||||
|
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||||
|
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
|
||||||
|
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
|
||||||
|
if (!im) continue;
|
||||||
|
if (im[1].toLowerCase() === 'x') continue;
|
||||||
|
nodes.push({ kind: 'item', text: im[2], line });
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (nodes[i].kind === 'heading') {
|
||||||
|
let hasItem = false;
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
if (nodes[j].kind === 'heading') break;
|
||||||
|
if (nodes[j].kind === 'item') { hasItem = true; break; }
|
||||||
|
}
|
||||||
|
if (!hasItem) continue;
|
||||||
|
}
|
||||||
|
out.push(nodes[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShopping(quiet = false) {
|
||||||
|
if (!quiet) shoppingErr('');
|
||||||
|
const data = await api('api/shopping');
|
||||||
|
const title = $('shopping-title');
|
||||||
|
const list = $('shopping-items');
|
||||||
|
if (!list) return;
|
||||||
|
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
|
||||||
|
// Dedup by card_uid — belt and suspenders in case the relay DB
|
||||||
|
// has stale rows from earlier schema migrations.
|
||||||
|
const seen = new Set();
|
||||||
|
const lists = (data.lists || []).filter(l => {
|
||||||
|
if (seen.has(l.card_uid)) return false;
|
||||||
|
seen.add(l.card_uid);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!lists.length) {
|
||||||
|
if (title) title.textContent = 'Shopping';
|
||||||
|
list.innerHTML = '<li class="empty">no active shopping lists</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (title) title.textContent = 'Shopping';
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const entry of lists) {
|
||||||
|
const nodes = shoppingParseLines(entry.body);
|
||||||
|
const hasItems = nodes.some(n => n.kind === 'item');
|
||||||
|
// List box wrapper
|
||||||
|
const box = document.createElement('li');
|
||||||
|
box.className = 'shopping-list-box';
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'shopping-list-header';
|
||||||
|
hdr.textContent = entry.title || '(untitled)';
|
||||||
|
box.appendChild(hdr);
|
||||||
|
if (!hasItems) {
|
||||||
|
const done = document.createElement('div');
|
||||||
|
done.className = 'shopping-list-done';
|
||||||
|
done.textContent = '\u2713 all done';
|
||||||
|
box.appendChild(done);
|
||||||
|
} else {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.kind === 'heading') {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'shopping-heading h' + n.level;
|
||||||
|
h.textContent = n.text;
|
||||||
|
box.appendChild(h);
|
||||||
|
} else {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'shopping-item';
|
||||||
|
row.textContent = n.text;
|
||||||
|
row.dataset.line = n.line;
|
||||||
|
row.dataset.uid = entry.card_uid;
|
||||||
|
row.addEventListener('click', () => shoppingCheck(row));
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.appendChild(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shoppingCheck(el) {
|
||||||
|
if (shoppingBusy) return;
|
||||||
|
shoppingBusy = true;
|
||||||
|
el.classList.add('checked');
|
||||||
|
try {
|
||||||
|
const res = await api('api/shopping/check', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { card_uid: el.dataset.uid, line: el.dataset.line, checked: true },
|
||||||
|
});
|
||||||
|
if (!res?.ok) throw new Error(res?.error || 'check failed');
|
||||||
|
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
|
||||||
|
} catch (e) {
|
||||||
|
shoppingBusy = false;
|
||||||
|
el.classList.remove('checked');
|
||||||
|
shoppingErr(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh when the tab comes back into focus — same pattern as
|
||||||
|
// the standalone page used to have, just without a manual button.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
if ($('view-shopping')?.classList.contains('hidden')) return;
|
||||||
|
if (shoppingBusy) return;
|
||||||
|
loadShopping(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chat ──
|
||||||
|
//
|
||||||
|
// Two thread kinds live side by side: 1:1 (identified by peer username)
|
||||||
|
// and group rooms (identified by room_uid). The UI keeps a single
|
||||||
|
// "active thread" descriptor and switches the fetch / send shape based
|
||||||
|
// on which field is populated.
|
||||||
|
|
||||||
|
let chatActive = null; // { kind: 'peer' | 'room', peer?, roomUid?, label }
|
||||||
let chatPollHandle = null;
|
let chatPollHandle = null;
|
||||||
|
|
||||||
function startChatPoll() {
|
function startChatPoll() {
|
||||||
stopChatPoll();
|
stopChatPoll();
|
||||||
chatPollHandle = setInterval(() => {
|
chatPollHandle = setInterval(() => {
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
|
if (chatActive) loadChatHistory(chatActive, true);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
function stopChatPoll() {
|
function stopChatPoll() {
|
||||||
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameActive(a, b) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.kind === b.kind && (a.peer === b.peer) && (a.roomUid === b.roomUid);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadChatThreads(quiet = false) {
|
async function loadChatThreads(quiet = false) {
|
||||||
const list = $('chat-thread-list');
|
const list = $('chat-thread-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const threads = await api('api/chat/threads') || [];
|
const threads = await api('api/chat/threads') || [];
|
||||||
if (!Array.isArray(threads)) return;
|
if (!Array.isArray(threads)) return;
|
||||||
|
|
||||||
// If no threads and not quiet, show empty state
|
|
||||||
if (!threads.length && !quiet) {
|
if (!threads.length && !quiet) {
|
||||||
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!threads.length) return;
|
if (!threads.length) return;
|
||||||
|
|
||||||
list.innerHTML = threads.map(t => `
|
list.innerHTML = threads.map(t => {
|
||||||
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
|
const isRoom = !!t.room_uid;
|
||||||
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
const desc = isRoom
|
||||||
<div style="flex:1;min-width:0;">
|
? { kind: 'room', roomUid: t.room_uid, label: t.name || t.room_uid }
|
||||||
<div class="peer-name">${escapeHtml(t.peer)}</div>
|
: { kind: 'peer', peer: t.peer, label: t.peer };
|
||||||
<div class="thread-time">${formatRelTime(t.last_at)}</div>
|
const active = isSameActive(chatActive, desc);
|
||||||
</div>
|
const icon = isRoom ? 'ph-users-three' : 'ph-user-circle';
|
||||||
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
const tag = isRoom ? 'room' : null;
|
||||||
</div>
|
const attrs = isRoom
|
||||||
`).join('');
|
? `data-kind="room" data-room-uid="${escapeHtml(t.room_uid)}" data-label="${escapeHtml(desc.label)}"`
|
||||||
|
: `data-kind="peer" data-peer="${escapeHtml(t.peer)}"`;
|
||||||
|
return `
|
||||||
|
<div class="chat-thread ${active ? 'active' : ''}" ${attrs}>
|
||||||
|
<i class="ph ${icon}" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="peer-name">${escapeHtml(desc.label)}${tag ? ` <span style="font-family:var(--pixel);font-size:8px;color:var(--dim);text-transform:uppercase;letter-spacing:0.5px;margin-left:4px;">${tag}</span>` : ''}</div>
|
||||||
|
<div class="thread-time">${escapeHtml(formatRelTime(t.last_at))}</div>
|
||||||
|
</div>
|
||||||
|
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
list.querySelectorAll('.chat-thread').forEach(el => {
|
list.querySelectorAll('.chat-thread').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
chatActivePeer = el.dataset.peer;
|
if (el.dataset.kind === 'room') {
|
||||||
|
chatActive = { kind: 'room', roomUid: el.dataset.roomUid, label: el.dataset.label };
|
||||||
|
} else {
|
||||||
|
chatActive = { kind: 'peer', peer: el.dataset.peer, label: el.dataset.peer };
|
||||||
|
}
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
loadChatHistory(chatActivePeer);
|
loadChatHistory(chatActive);
|
||||||
$('chat-input-bar')?.classList.remove('hidden');
|
$('chat-input-bar')?.classList.remove('hidden');
|
||||||
$('chat-peer-name').textContent = chatActivePeer;
|
$('chat-peer-name').textContent = chatActive.label;
|
||||||
$('chat-input')?.focus();
|
$('chat-input')?.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChatHistory(peer, quiet = false) {
|
async function loadChatHistory(active, quiet = false) {
|
||||||
const container = $('chat-messages');
|
const container = $('chat-messages');
|
||||||
if (!container) return;
|
if (!container || !active) return;
|
||||||
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
|
const qs = active.kind === 'room'
|
||||||
|
? 'room_uid=' + encodeURIComponent(active.roomUid)
|
||||||
|
: 'peer=' + encodeURIComponent(active.peer);
|
||||||
|
const messages = await api('api/chat/history?' + qs) || [];
|
||||||
if (!Array.isArray(messages)) return;
|
if (!Array.isArray(messages)) return;
|
||||||
|
|
||||||
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
||||||
@@ -719,8 +948,13 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group rooms need the sender's name visible above each incoming
|
||||||
|
// message (there isn't one single peer — it could be anyone in the
|
||||||
|
// room). 1:1 stays unlabeled for a tighter look.
|
||||||
|
const showSender = active.kind === 'room';
|
||||||
container.innerHTML = messages.map(m => `
|
container.innerHTML = messages.map(m => `
|
||||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||||
|
${showSender && !m.mine ? `<div class="msg-from" style="font-family:var(--pixel);font-size:9px;color:var(--accent2);margin-bottom:2px;">${escapeHtml(m.from)}</div>` : ''}
|
||||||
<div>${escapeHtml(m.body)}</div>
|
<div>${escapeHtml(m.body)}</div>
|
||||||
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,18 +964,20 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh thread list to clear unread badges
|
|
||||||
if (!quiet) loadChatThreads(true);
|
if (!quiet) loadChatThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendChatMessage() {
|
async function sendChatMessage() {
|
||||||
const input = $('chat-input');
|
const input = $('chat-input');
|
||||||
if (!input || !chatActivePeer) return;
|
if (!input || !chatActive) return;
|
||||||
const body = input.value.trim();
|
const body = input.value.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
|
const payload = chatActive.kind === 'room'
|
||||||
loadChatHistory(chatActivePeer);
|
? { body, room_uid: chatActive.roomUid }
|
||||||
|
: { body, recipient: chatActive.peer };
|
||||||
|
await api('api/chat/send', { method: 'POST', body: payload });
|
||||||
|
loadChatHistory(chatActive);
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,19 +1038,22 @@ async function loadUsers() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = users.map(u => `
|
list.innerHTML = users.map(u => {
|
||||||
|
const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
|
||||||
|
return `
|
||||||
<div class="user-row" data-id="${u.id}">
|
<div class="user-row" data-id="${u.id}">
|
||||||
<i class="ph ph-user-circle user-icon"></i>
|
<i class="ph ph-user-circle user-icon"></i>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||||
<div class="meta">created ${u.created_at}</div>
|
<div class="meta">created ${u.created_at} · ${seenText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
||||||
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
@@ -840,8 +1079,8 @@ async function createUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetUserPassword(id, username) {
|
async function resetUserPassword(id, username) {
|
||||||
const newPw = prompt(`new password for ${username} (min 4 chars):`);
|
const newPw = prompt(`new password for ${username} (min 10 chars):`);
|
||||||
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
|
if (!newPw || newPw.length < 10) { toast('password too short', true); return; }
|
||||||
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
||||||
if (res.ok) toast('password updated');
|
if (res.ok) toast('password updated');
|
||||||
else toast(res.error || 'failed', true);
|
else toast(res.error || 'failed', true);
|
||||||
@@ -980,3 +1219,187 @@ document.addEventListener('keydown', e => {
|
|||||||
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Read view: list + zen reader ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// The hyprcrd client pushes cards here when the user toggles "Read later"
|
||||||
|
// on a card. This view renders the list, and the reader overlay renders
|
||||||
|
// the markdown in a mobile-first, distraction-free column. Images in the
|
||||||
|
// body are rewritten to the relay's session-authed image endpoint.
|
||||||
|
|
||||||
|
let readCurrentUid = null;
|
||||||
|
|
||||||
|
async function loadReadList() {
|
||||||
|
const list = $('read-items');
|
||||||
|
const empty = $('read-empty');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await api('api/read');
|
||||||
|
const items = res?.items || [];
|
||||||
|
if (!items.length) {
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
list.innerHTML = items.map(it => {
|
||||||
|
const title = it.title?.trim() || '(untitled)';
|
||||||
|
const thumb = it.hero_filename
|
||||||
|
? `<div class="read-item-thumb" style="background-image:url('api/read-attachments/${encodeURIComponent(it.relay_uid)}/${encodeURIComponent(it.hero_filename)}');"></div>`
|
||||||
|
: `<div class="read-item-thumb read-item-thumb-placeholder"><i class="ph ph-book-open"></i></div>`;
|
||||||
|
return `
|
||||||
|
<li class="read-item" data-uid="${escapeHtml(it.relay_uid)}">
|
||||||
|
${thumb}
|
||||||
|
<div class="read-item-body">
|
||||||
|
<div class="read-item-title">${escapeHtml(title)}</div>
|
||||||
|
<div class="read-item-meta">${escapeHtml(formatRelTime(it.updated_at))}</div>
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.read-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => openReader(el.dataset.uid));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<li style="color:var(--danger);font-size:12px;">Failed to load: ${escapeHtml(e.message)}</li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReader(uid) {
|
||||||
|
const reader = $('read-reader');
|
||||||
|
const article = $('read-article');
|
||||||
|
if (!reader || !article) return;
|
||||||
|
readCurrentUid = uid;
|
||||||
|
article.innerHTML = '<p style="color:var(--dim);">loading…</p>';
|
||||||
|
reader.classList.remove('hidden');
|
||||||
|
reader.scrollTop = 0;
|
||||||
|
try {
|
||||||
|
const res = await api('api/read/' + encodeURIComponent(uid));
|
||||||
|
if (res?.error) throw new Error(res.error);
|
||||||
|
article.innerHTML = renderReadArticle(res);
|
||||||
|
} catch (e) {
|
||||||
|
article.innerHTML = `<p style="color:var(--danger);">Failed to load: ${escapeHtml(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReader() {
|
||||||
|
readCurrentUid = null;
|
||||||
|
$('read-reader')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadArticle(item) {
|
||||||
|
const title = item.title?.trim() || '(untitled)';
|
||||||
|
const rawBody = item.body || '';
|
||||||
|
// Hero: first uploads/<hex>.<ext> reference in body. Rewrite to the
|
||||||
|
// session-authed relay endpoint so the <img src> resolves server-side
|
||||||
|
// against the tenant's own read_attachments rows.
|
||||||
|
const uid = encodeURIComponent(item.relay_uid);
|
||||||
|
const bodyForRender = rawBody.replace(
|
||||||
|
/uploads\/([a-f0-9]+\.[a-z0-9]+)/gi,
|
||||||
|
(_m, fn) => `api/read-attachments/${uid}/${encodeURIComponent(fn)}`
|
||||||
|
);
|
||||||
|
const hero = item.hero_filename
|
||||||
|
? `<img class="read-hero" src="api/read-attachments/${uid}/${encodeURIComponent(item.hero_filename)}" alt="">`
|
||||||
|
: '';
|
||||||
|
const date = item.updated_at ? formatRelTime(item.updated_at) : '';
|
||||||
|
return `
|
||||||
|
${hero}
|
||||||
|
<h1 class="read-title">${escapeHtml(title)}</h1>
|
||||||
|
<div class="read-date">${escapeHtml(date)}</div>
|
||||||
|
${renderMarkdown(bodyForRender)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline markdown renderer. Covers the cases hyprcrd cards actually
|
||||||
|
// use: paragraphs, headings, bold/italic, inline code, links, bullet +
|
||||||
|
// numbered lists, blockquotes, hr, fenced code blocks, inline images.
|
||||||
|
// We do NOT pull a full library in — this matches the UI's existing
|
||||||
|
// minimal-deps stance (fonts + phosphor, nothing else).
|
||||||
|
function renderMarkdown(src) {
|
||||||
|
if (!src) return '';
|
||||||
|
const placeholders = [];
|
||||||
|
const stash = (html) => { placeholders.push(html); return `\u0000${placeholders.length - 1}\u0000`; };
|
||||||
|
|
||||||
|
// Fenced code
|
||||||
|
src = src.replace(/```(\w+)?\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
||||||
|
stash(`<pre><code>${escapeHtml(code)}</code></pre>`));
|
||||||
|
// Inline code
|
||||||
|
src = src.replace(/`([^`\n]+)`/g, (_m, c) => stash(`<code>${escapeHtml(c)}</code>`));
|
||||||
|
|
||||||
|
// Escape remaining HTML
|
||||||
|
src = escapeHtml(src);
|
||||||
|
|
||||||
|
// Restore stashed code first (before we mangle further)
|
||||||
|
src = src.replace(/\u0000(\d+)\u0000/g, (_m, i) => placeholders[Number(i)]);
|
||||||
|
|
||||||
|
// Images: 
|
||||||
|
src = src.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, url) =>
|
||||||
|
`<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}">`);
|
||||||
|
// Links: [text](url)
|
||||||
|
src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) =>
|
||||||
|
`<a href="${escapeAttr(url)}" target="_blank" rel="noopener">${text}</a>`);
|
||||||
|
|
||||||
|
// Headings (## etc.), line-anchored
|
||||||
|
src = src.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
|
||||||
|
src = src.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
|
||||||
|
src = src.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// HR
|
||||||
|
src = src.replace(/^---+$/gm, '<hr>');
|
||||||
|
|
||||||
|
// Blockquotes: consecutive "> " lines collapse into one <blockquote>
|
||||||
|
src = src.replace(/(^|\n)((?:> [^\n]*\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const inner = block.replace(/^> ?/gm, '').trim();
|
||||||
|
return `${pre}<blockquote>${inner.replace(/\n/g, '<br>')}</blockquote>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lists: grouped runs of "- " / "* " / "N." items
|
||||||
|
src = src.replace(/(^|\n)((?:[-*]\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const items = block.trim().split(/\n/).map(l => l.replace(/^[-*]\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
||||||
|
return `${pre}<ul>${items}</ul>\n`;
|
||||||
|
});
|
||||||
|
src = src.replace(/(^|\n)((?:\d+\.\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const items = block.trim().split(/\n/).map(l => l.replace(/^\d+\.\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
||||||
|
return `${pre}<ol>${items}</ol>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bold + italic (do bold first to avoid partial capture)
|
||||||
|
src = src.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
src = src.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Paragraphs: wrap any run of non-block-level lines. Block-level tags
|
||||||
|
// we've already emitted stay as-is.
|
||||||
|
const blocks = src.split(/\n{2,}/);
|
||||||
|
return blocks.map(b => {
|
||||||
|
const t = b.trim();
|
||||||
|
if (!t) return '';
|
||||||
|
if (/^<(h[1-6]|ul|ol|pre|blockquote|hr|img)/.test(t)) return t;
|
||||||
|
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return String(s ?? '').replace(/["&<>]/g, c => ({ '"': '"', '&': '&', '<': '<', '>': '>' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$('read-back')?.addEventListener('click', closeReader);
|
||||||
|
$('read-delete')?.addEventListener('click', async () => {
|
||||||
|
if (!readCurrentUid) return;
|
||||||
|
if (!await relayConfirm('Remove this item from your read list?', { okLabel: 'Remove', danger: true })) return;
|
||||||
|
const uid = readCurrentUid;
|
||||||
|
try {
|
||||||
|
await api('api/read/' + encodeURIComponent(uid), { method: 'DELETE' });
|
||||||
|
closeReader();
|
||||||
|
loadReadList();
|
||||||
|
toast('removed');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'failed', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browser back should close the reader first, then leave the view.
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && readCurrentUid) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeReader();
|
||||||
|
}
|
||||||
|
});
|
||||||
+67
-16
@@ -7,22 +7,28 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#1e293b">
|
<meta name="theme-color" content="#1e293b">
|
||||||
<title>hyprcrd relay</title>
|
<title>hyprcrd relay</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="fonts/phosphor.css">
|
<link rel="stylesheet" href="fonts/phosphor.css">
|
||||||
<link rel="stylesheet" href="ui/style.css">
|
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<i class="ph ph-broadcast brand-icon"></i>
|
<a onclick="showView('compose')" style="display:flex;align-items:center;gap:8px;text-decoration:none;cursor:pointer;">
|
||||||
<h1>hyprcrd relay</h1>
|
<i class="ph ph-broadcast brand-icon"></i>
|
||||||
|
<h1>hyprcrd relay</h1>
|
||||||
|
</a>
|
||||||
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
||||||
<div id="nav-buttons" class="hidden">
|
<div id="nav-buttons" class="hidden">
|
||||||
<span id="nav-user" class="user"></span>
|
<span id="nav-user" class="user"></span>
|
||||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||||
|
<button id="nav-inbox" class="nav-btn" onclick="showView('inbox')"><i class="ph ph-tray"></i> inbox</button>
|
||||||
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||||
|
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
|
||||||
|
<button id="nav-read" class="nav-btn" onclick="showView('read')"><i class="ph ph-book-open"></i> read</button>
|
||||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||||
@@ -42,7 +48,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>admin password</label>
|
<label>admin password</label>
|
||||||
<input type="password" id="setup-password" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="setup-password" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>super-shared secret</label>
|
<label>super-shared secret</label>
|
||||||
@@ -57,15 +63,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="view-login" class="auth-form hidden">
|
<div id="view-login" class="auth-form hidden">
|
||||||
<i class="ph ph-key auth-icon"></i>
|
<i class="ph ph-key auth-icon"></i>
|
||||||
<h2>login</h2>
|
<h2>login</h2>
|
||||||
<div class="field">
|
<form id="login-form" autocomplete="on" onsubmit="event.preventDefault(); doLogin();">
|
||||||
<label>username</label>
|
<div class="field">
|
||||||
<input type="text" id="login-username" autocomplete="username">
|
<label for="login-username">username</label>
|
||||||
</div>
|
<input type="text" id="login-username" name="username" autocomplete="username">
|
||||||
<div class="field">
|
</div>
|
||||||
<label>password</label>
|
<div class="field">
|
||||||
<input type="password" id="login-password" autocomplete="current-password">
|
<label for="login-password">password</label>
|
||||||
</div>
|
<input type="password" id="login-password" name="password" autocomplete="current-password">
|
||||||
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="ph ph-sign-in"></i> unlock</button>
|
||||||
|
</form>
|
||||||
<div id="login-err" class="err"></div>
|
<div id="login-err" class="err"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +113,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div id="compose-status" class="compose-status-bar"></div>
|
<div id="compose-status" class="compose-status-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Inbox view (inbox + shared buckets + sent) ── -->
|
||||||
|
<div id="view-inbox" class="hidden">
|
||||||
<div id="inbox-section" class="section">
|
<div id="inbox-section" class="section">
|
||||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
@@ -112,6 +124,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="inbox-list"></div>
|
<div id="inbox-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="shared-section" class="section">
|
||||||
|
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
|
||||||
|
<div id="shared-list"></div>
|
||||||
|
</div>
|
||||||
<div id="outbox-section" class="section">
|
<div id="outbox-section" class="section">
|
||||||
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
||||||
<div id="outbox-list"></div>
|
<div id="outbox-list"></div>
|
||||||
@@ -129,7 +145,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>new password</label>
|
<label>new password</label>
|
||||||
<input type="password" id="cp-new" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="cp-new" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>confirm new password</label>
|
<label>confirm new password</label>
|
||||||
@@ -190,7 +206,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-password">password</label>
|
<label for="new-password">password</label>
|
||||||
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="new-password" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
||||||
@@ -204,6 +220,41 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Shopping view ── -->
|
||||||
|
<!-- Phone-friendly checklist. Shows only unchecked "- [ ]" items
|
||||||
|
from the currently active shopping card. Tapping an item flips
|
||||||
|
it to "- [x]" on the relay in place; hyprcrd pulls that back on
|
||||||
|
the next sync tick. Edits to item text stay in hyprcrd. -->
|
||||||
|
<div id="view-shopping" class="hidden">
|
||||||
|
<div class="shopping-wrap">
|
||||||
|
<div class="shopping-top">
|
||||||
|
<span id="shopping-title" class="pixel" style="font-size:11px;color:var(--bright);">loading…</span>
|
||||||
|
</div>
|
||||||
|
<div id="shopping-err" class="shopping-err hidden"></div>
|
||||||
|
<ul id="shopping-items" class="shopping-items"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Read view: list + zen reader ── -->
|
||||||
|
<div id="view-read" class="hidden">
|
||||||
|
<div id="read-list" class="read-wrap">
|
||||||
|
<div id="read-empty" class="read-empty hidden">
|
||||||
|
<i class="ph ph-book-open read-empty-icon"></i>
|
||||||
|
<p>No items yet. Toggle <strong>Read later</strong> on a card in hyprcrd to send it here.</p>
|
||||||
|
</div>
|
||||||
|
<ul id="read-items" class="read-items"></ul>
|
||||||
|
</div>
|
||||||
|
<!-- Zen reader overlay -->
|
||||||
|
<div id="read-reader" class="read-reader hidden">
|
||||||
|
<div class="read-reader-bar">
|
||||||
|
<button id="read-back" class="read-reader-btn" aria-label="back"><i class="ph ph-arrow-left"></i></button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="read-delete" class="read-reader-btn read-reader-btn-danger" aria-label="delete" title="delete from read list"><i class="ph ph-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<article id="read-article" class="read-article"></article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Chat view ── -->
|
<!-- ── Chat view ── -->
|
||||||
<div id="view-chat" class="hidden">
|
<div id="view-chat" class="hidden">
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
@@ -241,6 +292,6 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
<div id="toast-stack"></div>
|
<div id="toast-stack"></div>
|
||||||
|
|
||||||
<script src="ui/app.js"></script>
|
<script src="ui/app.js?v=<?= filemtime(__DIR__ . '/app.js') ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+293
-29
@@ -232,6 +232,9 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
|||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: flex; flex-direction: column; gap: 16px;
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
}
|
}
|
||||||
|
#view-inbox:not(.hidden) {
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
.compose-attachments {
|
.compose-attachments {
|
||||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -254,6 +257,68 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
|||||||
display:flex; gap:8px; align-items:center;
|
display:flex; gap:8px; align-items:center;
|
||||||
}
|
}
|
||||||
.compose-card .actions .spacer { flex:1; }
|
.compose-card .actions .spacer { flex:1; }
|
||||||
|
|
||||||
|
/* Shopping view — phone-friendly checklist. Large tap targets,
|
||||||
|
unchecked items only, tap to flip. */
|
||||||
|
.shopping-wrap { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.shopping-top { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
|
||||||
|
.shopping-items { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.shopping-items .empty { list-style: none; text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
|
||||||
|
.shopping-items .empty:active { transform: none; }
|
||||||
|
|
||||||
|
/* Multi-list stacked boxes — no own border/background so items
|
||||||
|
don't look doubled. The header provides section separation. */
|
||||||
|
.shopping-list-box {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.shopping-list-header {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--bright);
|
||||||
|
padding: 4px 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.shopping-list-done {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 8px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.shopping-item {
|
||||||
|
background: var(--raised, var(--surface));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: transform 0.12s, opacity 0.3s, background 0.12s;
|
||||||
|
}
|
||||||
|
.shopping-item:active { transform: scale(0.98); }
|
||||||
|
.shopping-item.checked { opacity: 0.35; text-decoration: line-through; }
|
||||||
|
|
||||||
|
/* Section headings between items — "# Obst", "## Backwaren", etc.
|
||||||
|
Adds breathing room above so grouped items feel like sections. */
|
||||||
|
.shopping-heading {
|
||||||
|
padding: 14px 6px 2px;
|
||||||
|
cursor: default;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.shopping-heading:first-child { padding-top: 2px; }
|
||||||
|
.shopping-heading.h1 { font-size: 17px; color: var(--bright); }
|
||||||
|
.shopping-heading.h2 { font-size: 14px; color: var(--accent); }
|
||||||
|
.shopping-heading.h3,
|
||||||
|
.shopping-heading.h4,
|
||||||
|
.shopping-heading.h5,
|
||||||
|
.shopping-heading.h6 { font-size: 12px; color: var(--muted); }
|
||||||
|
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
|
||||||
|
.shopping-err.hidden { display: none; }
|
||||||
|
|
||||||
/* Chat view */
|
/* Chat view */
|
||||||
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
||||||
@@ -607,23 +672,28 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
display:flex !important;
|
display:flex !important;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
align-items:stretch;
|
align-items:stretch;
|
||||||
position:absolute;
|
position:fixed;
|
||||||
top:100%;
|
top: 48px;
|
||||||
right:12px;
|
left: 0;
|
||||||
margin-top:6px;
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0;
|
||||||
background:var(--surface);
|
background:var(--surface);
|
||||||
border:1px solid var(--border);
|
border-top:1px solid var(--border);
|
||||||
border-radius:var(--radius-lg);
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-popover);
|
padding: 12px 16px;
|
||||||
padding:6px;
|
gap: 6px;
|
||||||
gap:4px;
|
z-index:100;
|
||||||
min-width:180px;
|
|
||||||
z-index:50;
|
|
||||||
}
|
}
|
||||||
#nav-buttons.menu-open .nav-btn {
|
#nav-buttons.menu-open .nav-btn {
|
||||||
text-align:left;
|
text-align:left;
|
||||||
width:100%;
|
width:100%;
|
||||||
padding: 8px 12px;
|
padding: 14px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
#nav-buttons.menu-open .nav-btn .ph {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-admin .section {
|
#view-admin .section {
|
||||||
@@ -687,12 +757,11 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compose view: 2-column grid */
|
/* Compose view: single column, centered. Inbox/shared/sent are in
|
||||||
|
their own view now — no sidebar to fill the right-hand space. */
|
||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
max-width: 720px;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
margin: 0 auto;
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards: depth via shadow + larger padding */
|
/* Cards: depth via shadow + larger padding */
|
||||||
@@ -734,18 +803,6 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox card sticks under the header */
|
|
||||||
#inbox-section {
|
|
||||||
position: sticky;
|
|
||||||
top: 24px;
|
|
||||||
transition: box-shadow .15s;
|
|
||||||
}
|
|
||||||
#inbox-section:hover {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0,0,0,0.3),
|
|
||||||
0 12px 32px -8px rgba(0,0,0,0.5),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin: 2-column grid */
|
/* Admin: 2-column grid */
|
||||||
#view-admin:not(.hidden) .section {
|
#view-admin:not(.hidden) .section {
|
||||||
@@ -782,3 +839,210 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
||||||
.auth-form .auth-icon { font-size: 44px; }
|
.auth-form .auth-icon { font-size: 44px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
|
* Read view — list + distraction-free zen reader. Mobile-first; the
|
||||||
|
* article width is capped tight for comfortable line-lengths and the
|
||||||
|
* type scale is larger than anywhere else in the UI because this is
|
||||||
|
* meant for actual reading, not scanning.
|
||||||
|
* ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.read-wrap {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.read-empty-icon { font-size: 48px; display: block; margin: 0 auto 16px; color: var(--muted); }
|
||||||
|
|
||||||
|
.read-items {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.read-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.read-item:hover { border-color: var(--accent); background: var(--raised); }
|
||||||
|
.read-item-thumb {
|
||||||
|
width: 64px; height: 64px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg) center/cover no-repeat;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.read-item-thumb-placeholder {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.read-item-body { flex: 1; min-width: 0; }
|
||||||
|
.read-item-title {
|
||||||
|
color: var(--bright);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.read-item-meta {
|
||||||
|
font-family: var(--pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader overlay: covers the main content area; header stays visible
|
||||||
|
* so the user can navigate back via the burger / nav buttons. */
|
||||||
|
.read-reader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 56px 0 0 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 80;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.read-reader-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.read-reader-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.read-reader-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.read-reader-btn-danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
.read-article {
|
||||||
|
max-width: 680px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 96px;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.read-article .read-hero {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 360px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.read-article h1.read-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.read-article .read-date {
|
||||||
|
font-family: var(--pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.read-article h1, .read-article h2, .read-article h3 {
|
||||||
|
color: var(--bright);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.read-article h2 { font-size: 20px; }
|
||||||
|
.read-article h3 { font-size: 17px; }
|
||||||
|
.read-article p { margin: 0 0 16px; }
|
||||||
|
.read-article a { color: var(--accent); text-decoration: underline; }
|
||||||
|
.read-article ul, .read-article ol { padding-left: 24px; margin: 0 0 16px; }
|
||||||
|
.read-article li { margin-bottom: 6px; }
|
||||||
|
.read-article code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.read-article pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.read-article pre code { background: none; padding: 0; }
|
||||||
|
.read-article blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.read-article img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.read-article hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
.read-article table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.read-article th, .read-article td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: pad tighter, bump touch targets */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.read-article { font-size: 16px; padding: 20px 16px 96px; }
|
||||||
|
.read-article h1.read-title { font-size: 22px; }
|
||||||
|
.read-reader { inset: 52px 0 0 0; }
|
||||||
|
.read-reader-btn { width: 40px; height: 40px; font-size: 18px; }
|
||||||
|
.read-item-thumb { width: 52px; height: 52px; }
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class CalDavClient
|
|||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
if ($body !== '') {
|
if ($body !== '') {
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user