From 5f9db690a475b7c6b2b960e4198b7e01c3b5a3a1 Mon Sep 17 00:00:00 2001 From: th Date: Thu, 9 Apr 2026 19:14:52 +0200 Subject: [PATCH] bak --- README.md | 111 +++++- config.php | 2 +- dev.sh | 13 + docker-compose.yml | 13 + public/css/style.css | 4 + public/css/tailwind.out.css | 2 +- public/index.php | 598 +++++++++++++++++++++-------- public/js/app.js | 19 +- public/js/modules/board.js | 211 +++++++++- public/js/modules/buckets.js | 15 +- public/js/modules/caldav.js | 82 ++-- public/js/modules/context-menu.js | 12 + public/js/modules/gallery.js | 35 +- public/js/modules/theme.js | 31 +- relay/.htaccess | 8 + relay/Dockerfile | 11 + relay/dist.sh | 16 + relay/fonts/jetbrains-mono-400.ttf | Bin 0 -> 112172 bytes relay/fonts/silkscreen-400.ttf | Bin 0 -> 31320 bytes relay/fonts/silkscreen-700.ttf | Bin 0 -> 29744 bytes relay/index.php | 476 +++++++++++++++++++++++ src/CalDavSync.php | 3 + src/Database.php | 13 + src/WebDavNotes.php | 369 ------------------ 24 files changed, 1447 insertions(+), 597 deletions(-) create mode 100755 dev.sh create mode 100644 relay/.htaccess create mode 100644 relay/Dockerfile create mode 100755 relay/dist.sh create mode 100644 relay/fonts/jetbrains-mono-400.ttf create mode 100644 relay/fonts/silkscreen-400.ttf create mode 100644 relay/fonts/silkscreen-700.ttf create mode 100644 relay/index.php delete mode 100644 src/WebDavNotes.php diff --git a/README.md b/README.md index e687023..b9352ee 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,12 @@ hyprcrd/ ├── config.php # App constants (auto-configured) ├── Dockerfile ├── docker-entrypoint.sh +├── dist.sh # Build distribution ZIP ├── src/ │ ├── bootstrap.php # Initialization -│ └── Database.php # SQLite schema & migrations +│ ├── Database.php # SQLite schema & migrations +│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser +│ └── CalDavSync.php # Bidirectional CalDAV sync engine ├── public/ # Document root │ ├── index.php # Entry point & API │ ├── .htaccess # URL rewriting @@ -104,6 +107,11 @@ hyprcrd/ │ │ ├── modules/ # Feature modules │ │ └── vendor/ # marked.js, DOMPurify │ └── uploads/ # User media (writable) +├── relay/ # Relay service (mobile inbox + card sharing) +│ ├── index.php # Single-file API + Mobile UI +│ ├── .htaccess # URL rewriting + data protection +│ ├── Dockerfile +│ └── dist.sh # Build relay distribution ZIP └── data/ └── hyprcrd.db # SQLite database (auto-created) ``` @@ -123,6 +131,11 @@ hyprcrd/ - **CalDAV sync** with ownCloud/Nextcloud (bidirectional, `#termin` tagged cards) - **Inline calculator** (`?3+5` → `8`, `34+56?` → `34+56=90`) - **Snippets** (`?dat`, `?time`, `?now`, `?todo`, `?table`, `?code`, ...) +- **Due dates** with overdue notifications and pulsing cards +- **Relay** for mobile note capture and card sharing between instances +- **Notification banner** for upcoming events, birthdays, overdue tasks +- **Board split view** with side-by-side preview +- **Superview** compact multi-column list with hover cards - **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete ## Data & Backup @@ -204,6 +217,99 @@ The built-in calendar shows cards on their dates: - Appointment template includes Zeit, Ort, Teilnehmer, Notizen fields - Navigate months with arrow buttons, **today** jumps to current date +## Vault Key (Multi-Instance Encryption) + +When running multiple hyprcrd instances that share CalDAV credentials, you need a shared encryption key. + +### Setup + +1. Go to **Settings > Account > Vault Key** +2. Enter the same key (min 8 characters) on all instances +3. Click **save** + +### How it works + +- The vault key encrypts/decrypts CalDAV credentials stored in the database +- Key derivation: `SHA-256(vault_key)` → deterministic, same on all instances +- Without a vault key, encryption falls back to the login password hash (single-instance only) +- Changing the vault key automatically re-encrypts CalDAV credentials +- Changing the login password does NOT affect encryption when a vault key is set + +### Important + +- The vault key is stored in plaintext in each instance's database — it is NOT synced +- You must enter it manually on each connected instance + +## Relay + +A minimal web service for mobile note capture and card sharing between hyprcrd instances. + +### Architecture + +``` +[Phone/Browser] → https://relay.example.com → [Relay Service] + ↓ poll + [hyprcrd (local)] + +[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B] +``` + +### Deploy to Webspace + +```bash +cd relay +./dist.sh +``` + +Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret. + +Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`. + +### Deploy with Docker + +```bash +cd relay +docker build -t hyprcrd-relay . +docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay +``` + +### Development (both together) + +```bash +./dev.sh +``` + +Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. + +From inside the hyprcrd container, the relay is reachable at `http://relay:80`. + +### Setup + +1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption) +2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret +3. Click **save** + +### How it works + +- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag +- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll +- Messages are encrypted client-side (AES-256-CBC) before sending to the relay +- The relay only stores ciphertext — it cannot read your messages +- hyprcrd polls the relay every 45 seconds in the background +- Messages are kept on the relay for 24 hours, then auto-deleted +- Cards pushed from the same instance are not re-imported +- Enter to send, Shift+Enter for newline + +### Relay Security + +- Separate password (UI login) and shared secret (API + encryption) +- Bcrypt hashing for both credentials +- Rate-limited login (5 attempts/minute) +- AES-256-CBC client-side encryption — relay never sees plaintext +- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options) +- SQLite database blocked from direct download via `.htaccess` +- Request size limited to 256KB + ## Rebuilding Tailwind CSS After changing Tailwind utility classes in HTML or JS files: @@ -224,6 +330,9 @@ curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/ta - Upload directory blocks PHP execution - DOMPurify sanitizes all rendered markdown - Security headers (X-Frame-Options, HSTS, etc.) +- AES-256-CBC encryption for CalDAV credentials and Relay messages +- Vault key system for multi-instance CalDAV encryption (independent of login password) +- Relay: separate password + shared secret, rate-limited, client-side encryption ## License diff --git a/config.php b/config.php index fc1fdf6..c8201b6 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ :not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-t-accent{border-color:var(--color-t-accent)}.border-t-border{border-color:var(--color-t-border)}.border-t-bright{border-color:var(--color-t-bright)}.border-t-danger,.border-t-danger\/30{border-color:var(--color-t-danger)}@supports (color:color-mix(in lab, red, red)){.border-t-danger\/30{border-color:color-mix(in oklab, var(--color-t-danger) 30%, transparent)}}.border-transparent{border-color:#0000}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-t-accent{background-color:var(--color-t-accent)}.bg-t-bg{background-color:var(--color-t-bg)}.bg-t-border{background-color:var(--color-t-border)}.bg-t-raised{background-color:var(--color-t-raised)}.bg-t-surface{background-color:var(--color-t-surface)}.bg-transparent{background-color:#0000}.bg-cover{background-size:cover}.bg-center{background-position:50%}.object-cover{object-fit:cover}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-16{padding-right:calc(var(--spacing) * 16)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-pixel{font-family:var(--font-pixel)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[18px\]{font-size:18px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-green-400{color:var(--color-green-400)}.text-t-accent{color:var(--color-t-accent)}.text-t-accent2{color:var(--color-t-accent2)}.text-t-bg{color:var(--color-t-bg)}.text-t-bright{color:var(--color-t-bright)}.text-t-danger{color:var(--color-t-danger)}.text-t-dim{color:var(--color-t-dim)}.text-t-muted{color:var(--color-t-muted)}.text-t-text{color:var(--color-t-text)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.line-through{text-decoration-line:line-through}.accent-t-accent{accent-color:var(--color-t-accent)}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_-4px_20px_-4px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 -4px 20px -4px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_20px_-4px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 4px 20px -4px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-t-accent{--tw-ring-color:var(--color-t-accent)}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-t-bg{--tw-ring-offset-color:var(--color-t-bg)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:opacity-75:is(:where(.group):hover *){opacity:.75}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-t-dim::placeholder{color:var(--color-t-dim)}@media (hover:hover){.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-125:hover{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-t-accent:hover{border-color:var(--color-t-accent)}.hover\:border-t-accent2:hover{border-color:var(--color-t-accent2)}.hover\:border-t-danger:hover{border-color:var(--color-t-danger)}.hover\:border-t-muted:hover{border-color:var(--color-t-muted)}.hover\:bg-t-accent:hover,.hover\:bg-t-accent\/10:hover{background-color:var(--color-t-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-accent\/10:hover{background-color:color-mix(in oklab, var(--color-t-accent) 10%, transparent)}}.hover\:bg-t-danger:hover{background-color:var(--color-t-danger)}.hover\:bg-t-raised:hover,.hover\:bg-t-raised\/40:hover{background-color:var(--color-t-raised)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-raised\/40:hover{background-color:color-mix(in oklab, var(--color-t-raised) 40%, transparent)}}.hover\:bg-t-raised\/50:hover{background-color:var(--color-t-raised)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-raised\/50:hover{background-color:color-mix(in oklab, var(--color-t-raised) 50%, transparent)}}.hover\:text-t-accent:hover{color:var(--color-t-accent)}.hover\:text-t-accent2:hover{color:var(--color-t-accent2)}.hover\:text-t-bg:hover{color:var(--color-t-bg)}.hover\:text-t-bright:hover{color:var(--color-t-bright)}.hover\:text-t-danger:hover{color:var(--color-t-danger)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-t-accent:focus{border-color:var(--color-t-accent)}.focus\:border-t-accent2:focus{border-color:var(--color-t-accent2)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-green-400:oklch(79.2% .209 151.711);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-pixel:"Silkscreen", cursive;--color-t-bg:var(--t-bg);--color-t-surface:var(--t-surface);--color-t-raised:var(--t-raised);--color-t-border:var(--t-border);--color-t-dim:var(--t-dim);--color-t-muted:var(--t-muted);--color-t-text:var(--t-text);--color-t-bright:var(--t-bright);--color-t-accent:var(--t-accent);--color-t-accent2:var(--t-accent2);--color-t-danger:var(--t-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-0{right:calc(var(--spacing) * 0)}.bottom-16{bottom:calc(var(--spacing) * 16)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.z-40{z-index:40}.z-\[1\]{z-index:1}.z-\[45\]{z-index:45}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.z-\[72\]{z-index:72}.z-\[73\]{z-index:73}.z-\[75\]{z-index:75}.z-\[80\]{z-index:80}.z-\[85\]{z-index:85}.z-\[88\]{z-index:88}.z-\[90\]{z-index:90}.z-\[95\]{z-index:95}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-5{-webkit-line-clamp:5;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:calc(var(--spacing) * 32)}.max-h-36{max-height:calc(var(--spacing) * 36)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-full{max-height:100%}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-full{min-height:100%}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\/5{width:40%}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-16{width:calc(var(--spacing) * 16)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[120px\]{min-width:120px}.min-w-\[220px\]{min-width:220px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-125{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x) var(--tw-scale-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[repeat\(auto-fill\,minmax\(140px\,1fr\)\)\]{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}.grid-cols-\[repeat\(auto-fill\,minmax\(280px\,1fr\)\)\]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.content-start{align-content:flex-start}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.self-center{align-self:center}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-t-accent{border-color:var(--color-t-accent)}.border-t-border{border-color:var(--color-t-border)}.border-t-bright{border-color:var(--color-t-bright)}.border-t-danger,.border-t-danger\/30{border-color:var(--color-t-danger)}@supports (color:color-mix(in lab, red, red)){.border-t-danger\/30{border-color:color-mix(in oklab, var(--color-t-danger) 30%, transparent)}}.border-transparent{border-color:#0000}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-t-accent{background-color:var(--color-t-accent)}.bg-t-bg{background-color:var(--color-t-bg)}.bg-t-border{background-color:var(--color-t-border)}.bg-t-raised{background-color:var(--color-t-raised)}.bg-t-surface{background-color:var(--color-t-surface)}.bg-transparent{background-color:#0000}.bg-cover{background-size:cover}.bg-center{background-position:50%}.object-cover{object-fit:cover}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-16{padding-right:calc(var(--spacing) * 16)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-pixel{font-family:var(--font-pixel)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[18px\]{font-size:18px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-green-400{color:var(--color-green-400)}.text-t-accent{color:var(--color-t-accent)}.text-t-accent2{color:var(--color-t-accent2)}.text-t-bg{color:var(--color-t-bg)}.text-t-bright{color:var(--color-t-bright)}.text-t-danger{color:var(--color-t-danger)}.text-t-dim{color:var(--color-t-dim)}.text-t-muted{color:var(--color-t-muted)}.text-t-text{color:var(--color-t-text)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.line-through{text-decoration-line:line-through}.accent-t-accent{accent-color:var(--color-t-accent)}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_-4px_20px_-4px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 -4px 20px -4px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_20px_-4px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 4px 20px -4px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-t-accent{--tw-ring-color:var(--color-t-accent)}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-t-bg{--tw-ring-offset-color:var(--color-t-bg)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:opacity-75:is(:where(.group):hover *){opacity:.75}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-t-dim::placeholder{color:var(--color-t-dim)}@media (hover:hover){.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-125:hover{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-t-accent:hover{border-color:var(--color-t-accent)}.hover\:border-t-accent2:hover{border-color:var(--color-t-accent2)}.hover\:border-t-danger:hover{border-color:var(--color-t-danger)}.hover\:border-t-muted:hover{border-color:var(--color-t-muted)}.hover\:bg-t-accent:hover,.hover\:bg-t-accent\/10:hover{background-color:var(--color-t-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-accent\/10:hover{background-color:color-mix(in oklab, var(--color-t-accent) 10%, transparent)}}.hover\:bg-t-danger:hover{background-color:var(--color-t-danger)}.hover\:bg-t-raised:hover,.hover\:bg-t-raised\/40:hover{background-color:var(--color-t-raised)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-raised\/40:hover{background-color:color-mix(in oklab, var(--color-t-raised) 40%, transparent)}}.hover\:bg-t-raised\/50:hover{background-color:var(--color-t-raised)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-t-raised\/50:hover{background-color:color-mix(in oklab, var(--color-t-raised) 50%, transparent)}}.hover\:text-t-accent:hover{color:var(--color-t-accent)}.hover\:text-t-accent2:hover{color:var(--color-t-accent2)}.hover\:text-t-bg:hover{color:var(--color-t-bg)}.hover\:text-t-bright:hover{color:var(--color-t-bright)}.hover\:text-t-danger:hover{color:var(--color-t-danger)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-t-accent:focus{border-color:var(--color-t-accent)}.focus\:border-t-accent2:focus{border-color:var(--color-t-accent2)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false} \ No newline at end of file diff --git a/public/index.php b/public/index.php index f9438fb..31f3aa0 100644 --- a/public/index.php +++ b/public/index.php @@ -3,7 +3,6 @@ require_once __DIR__ . '/../src/bootstrap.php'; require_once __DIR__ . '/../src/CalDavClient.php'; require_once __DIR__ . '/../src/CalDavSync.php'; -require_once __DIR__ . '/../src/WebDavNotes.php'; $db = Database::connect(); @@ -105,6 +104,49 @@ if (str_starts_with($route, '/api/auth')) { exit; } + // GET/POST /api/auth/vault-key + if ($route === '/api/auth/vault-key' && $method === 'GET') { + $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); + echo json_encode(['has_key' => !empty($vk)]); exit; + } + if ($route === '/api/auth/vault-key' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + $newKey = $input['key'] ?? ''; + if (strlen($newKey) < 8) { http_response_code(400); echo json_encode(['error' => 'min 8 characters']); exit; } + + $oldVk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); + $oldKey = $oldVk ? substr(hash('sha256', $oldVk), 0, 32) : null; + if (!$oldKey) { + // Fallback: try old password-hash-based key + $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); + $oldKey = substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); + } + $newKeyDerived = substr(hash('sha256', $newKey), 0, 32); + + // Re-encrypt CalDAV credentials + $credKeys = ['caldav_url', 'caldav_username', 'caldav_password']; + foreach ($credKeys as $ck) { + $enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn(); + if ($enc) { + $raw = base64_decode($enc); + if (strlen($raw) >= 17) { + $iv = substr($raw, 0, 16); + $cipher = base64_encode(substr($raw, 16)); + $plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv); + if ($plain !== false) { + $newIv = random_bytes(16); + $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKeyDerived, 0, $newIv); + $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => base64_encode($newIv . base64_decode($newCipher)), 'k' => $ck]); + } + } + } + } + + $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('vault_key', :v)")->execute(['v' => $newKey]); + + echo json_encode(['ok' => true, 'rekey' => true]); exit; + } + // POST /api/auth/password — change password (requires current password) if ($route === '/api/auth/password' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); @@ -121,49 +163,42 @@ if (str_starts_with($route, '/api/auth')) { echo json_encode(['error' => 'min 4 characters']); exit; } - // Re-encrypt CalDAV credentials with new key (atomic — rollback on failure) - $oldKey = substr(hash('sha256', $hash), 0, 32); $newHash = password_hash($newPass, PASSWORD_BCRYPT); - $newKey = substr(hash('sha256', $newHash), 0, 32); + $hasVaultKey = !empty($db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn()); - $credKeys = ['caldav_url', 'caldav_username', 'caldav_password']; - $reEncrypted = []; - $rekeyOk = true; + if ($hasVaultKey) { + // Vault key handles encryption — just change the password + $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); + echo json_encode(['ok' => true]); + } else { + // Legacy: re-encrypt credentials with new password hash + $oldKey = substr(hash('sha256', $hash), 0, 32); + $newKey = substr(hash('sha256', $newHash), 0, 32); + $credKeys = ['caldav_url', 'caldav_username', 'caldav_password']; + $reEncrypted = []; + $rekeyOk = true; - foreach ($credKeys as $ck) { - $enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn(); - if (!$enc) continue; - $raw = base64_decode($enc); - if (strlen($raw) < 17) continue; - $iv = substr($raw, 0, 16); - $cipher = base64_encode(substr($raw, 16)); - $plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv); - if ($plain === false) { $rekeyOk = false; break; } - $newIv = random_bytes(16); - $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKey, 0, $newIv); - $reEncrypted[$ck] = base64_encode($newIv . base64_decode($newCipher)); + foreach ($credKeys as $ck) { + $enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn(); + if (!$enc) continue; + $raw = base64_decode($enc); + if (strlen($raw) < 17) continue; + $plain = openssl_decrypt(base64_encode(substr($raw, 16)), 'aes-256-cbc', $oldKey, 0, substr($raw, 0, 16)); + if ($plain === false) { $rekeyOk = false; break; } + $newIv = random_bytes(16); + $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKey, 0, $newIv); + $reEncrypted[$ck] = base64_encode($newIv . base64_decode($newCipher)); + } + + if (!$rekeyOk) { http_response_code(500); echo json_encode(['error' => 'set a vault key first to avoid encryption issues']); exit; } + + $db->beginTransaction(); + foreach ($reEncrypted as $ck => $val) $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]); + $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); + $db->commit(); + $db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'"); + echo json_encode(['ok' => true, 'rekey' => true]); } - - if (!$rekeyOk) { - http_response_code(500); - echo json_encode(['error' => 'could not re-encrypt credentials — password NOT changed. Please re-enter CalDAV credentials first.']); - exit; - } - - // All re-encryption succeeded — commit everything - $db->beginTransaction(); - // Save old hash for re-key recovery - $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('prev_password_hash', :h)")->execute(['h' => $hash]); - foreach ($reEncrypted as $ck => $val) { - $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]); - } - $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); - $db->commit(); - - // Mark all notes for re-push (re-encrypt with new key) - $db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'"); - - echo json_encode(['ok' => true, 'rekey' => true]); exit; } @@ -239,7 +274,7 @@ if (str_starts_with($route, '/api/')) { } 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, 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, 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; } @@ -595,6 +630,132 @@ if (str_starts_with($route, '/api/')) { exit; } + // Import Logseq vault + if ($route === '/api/import-logseq' && $method === 'POST') { + if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); echo json_encode(['error' => 'no file']); exit; + } + $zip = new ZipArchive(); + if ($zip->open($_FILES['file']['tmp_name']) !== true) { + http_response_code(400); echo json_encode(['error' => 'invalid zip']); exit; + } + + $uploadDir = __DIR__ . '/uploads'; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); + + // Find the vault root (directory containing journals/ or pages/) + $prefix = ''; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } + if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } + } + + // 1. Copy assets → uploads with name mapping + $assetMap = []; // original filename → new hex filename + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (!str_starts_with($name, $prefix . 'assets/')) continue; + if (str_ends_with($name, '/')) continue; + $content = $zip->getFromIndex($i); + if ($content === false) continue; + $origName = basename($name); + $ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin'; + $newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext); + file_put_contents($uploadDir . '/' . $newName, $content); + $assetMap[$origName] = $newName; + } + + // Helper: rewrite image references in markdown body + $rewriteImages = function($body) use ($assetMap) { + // Logseq patterns: ![desc](../assets/file.png), ![desc](/assets/file.png), ![desc](assets/file.png) + $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function($m) use ($assetMap) { + $file = basename($m[2]); + if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; + return $m[0]; + }, $body); + // Logseq block-level image embed: {{embed ((image))}} or just bare refs + // Also handle: ![image.png](../assets/image.png) with URL-encoded filenames + $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function($m) use ($assetMap) { + $file = urldecode(basename($m[2])); + if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; + return $m[0]; + }, $body); + return $body; + }; + + // Helper: clean Logseq markdown (remove bullet prefixes, properties) + $cleanBody = function($body) { + $lines = explode("\n", $body); + $cleaned = []; + foreach ($lines as $line) { + // Skip Logseq properties (key:: value at start) + if (preg_match('/^[a-z_-]+::\s/', $line)) continue; + // Remove leading "- " bullet outline format (Logseq default) + $line = preg_replace('/^(\t*)- /', '$1', $line); + $cleaned[] = $line; + } + return implode("\n", $cleaned); + }; + + // Get or create "import" bucket + $importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn(); + if (!$importBucket) { + $db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute(); + $importBucket = $db->lastInsertId(); + } + + $imported = 0; + + // 2. Import journals → cards with date title + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (!str_starts_with($name, $prefix . 'journals/')) continue; + if (!str_ends_with($name, '.md')) continue; + $content = $zip->getFromIndex($i); + if ($content === false || !trim($content)) continue; + + $basename = pathinfo(basename($name), PATHINFO_FILENAME); + // 2025_06_18 → 2025-06-18 + $title = str_replace('_', '-', $basename); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue; + + $body = $rewriteImages($cleanBody($content)); + // Extract tags + preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); + $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); + + $db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)') + ->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']); + $imported++; + } + + // 3. Import pages → cards with page name as title + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (!str_starts_with($name, $prefix . 'pages/')) continue; + if (!str_ends_with($name, '.md')) continue; + $content = $zip->getFromIndex($i); + if ($content === false || !trim($content)) continue; + + $title = pathinfo(basename($name), PATHINFO_FILENAME); + // Skip Logseq internal pages + if (in_array(strtolower($title), ['contents', 'favorites'])) continue; + + $body = $rewriteImages($cleanBody($content)); + preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); + $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); + + $db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)') + ->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]); + $imported++; + } + + $zip->close(); + echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]); + exit; + } + // CalDAV endpoints $caldav = new CalDavSync($db); @@ -616,131 +777,223 @@ if (str_starts_with($route, '/api/')) { echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit; } - // WebDAV Notes sync - if ($route === '/api/notes/sync' && $method === 'POST') { - $notes = new WebDavNotes($db); - echo json_encode($notes->sync()); exit; + // ── Relay ── + + if ($route === '/api/relay/settings' && $method === 'GET') { + $url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: ''; + $hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn()); + echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit; } - // Re-key notes: decrypt with old key, re-encrypt with current key - if ($route === '/api/notes/rekey' && $method === 'POST') { + if ($route === '/api/relay/settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); - $oldPass = $input['old_password'] ?? ''; - if (!$oldPass) { http_response_code(400); echo json_encode(['error' => 'old password required']); exit; } + if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]); + if (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]); + echo json_encode(['ok' => true]); exit; + } - $currentHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); - $currentKey = substr(hash('sha256', $currentHash), 0, 32); - $prevHash = $db->query("SELECT value FROM settings WHERE key = 'prev_password_hash'")->fetchColumn(); + if ($route === '/api/relay/poll' && $method === 'POST') { + $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); + $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); + if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; } - // Build candidate old keys: try prev hash first, then verify password against it - $candidateKeys = []; - if ($prevHash) { - $candidateKeys[] = substr(hash('sha256', $prevHash), 0, 32); - // Maybe password was changed multiple times — prev_hash might not match oldPass - if (password_verify($oldPass, $prevHash)) { - // Confirmed: prev hash matches old password - array_unshift($candidateKeys, substr(hash('sha256', $prevHash), 0, 32)); + $ch = curl_init(rtrim($relayUrl, '/') . '/api/messages'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; } + + $messages = json_decode($response, true); + if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; } + + $pulled = 0; + $encKey = substr(hash('sha256', $relayKey), 0, 32); + + // Load IDs we pushed ourselves + $ownIds = array_column($db->query('SELECT relay_msg_id FROM relay_pushed')->fetchAll(), 'relay_msg_id'); + + foreach ($messages as $msg) { + if ($msg['fetched'] ?? false) continue; + if (in_array($msg['id'] ?? 0, $ownIds)) { + // Clean up tracking row since relay will delete it after 24h anyway + $db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]); + continue; } - } - // Also try current key (notes might already be on current key) - $candidateKeys[] = $currentKey; - // Get WebDAV client - $caldavSync = new CalDavSync($db); - $settings = $caldavSync->getSettings(); - if (empty($settings['url'])) { echo json_encode(['ok' => false, 'error' => 'no CalDAV credentials']); exit; } + // Decrypt + $title = $msg['title'] ?? ''; + $body = $msg['body'] ?? ''; + if (str_starts_with($body, 'HYPRCRD-RELAY-V1')) { + $enc = trim(explode("\n", $body, 2)[1] ?? ''); + $raw = base64_decode($enc); + if (strlen($raw) >= 17) { + $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); + if ($plain !== false) $body = $plain; + } + } + if (str_starts_with($title, 'HYPRCRD-RELAY-V1')) { + $enc = trim(explode("\n", $title, 2)[1] ?? ''); + $raw = base64_decode($enc); + if (strlen($raw) >= 17) { + $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); + if ($plain !== false) $title = $plain; + } + } - $url = $settings['url']; - $user = $settings['username']; - $passEnc = $db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn(); - $passRaw = base64_decode($passEnc); - $passIv = substr($passRaw, 0, 16); - $passCipher = base64_encode(substr($passRaw, 16)); - $pass = openssl_decrypt($passCipher, 'aes-256-cbc', $currentKey, 0, $passIv) ?: ''; + if (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message'; + $body = trim($body); + $relayUid = $msg['relay_uid'] ?? null; - if (!$pass) { echo json_encode(['ok' => false, 'error' => 'cannot decrypt CalDAV password']); exit; } + // Check for existing card with this relay_uid + $existingCard = null; + if ($relayUid) { + $stmt = $db->prepare('SELECT id FROM cards WHERE relay_uid = ?'); + $stmt->execute([$relayUid]); + $existingCard = $stmt->fetch(); + } - if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) { - $notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/'; - } else { echo json_encode(['ok' => false, 'error' => 'cannot derive notes URL']); exit; } + preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); - $client = new CalDavClient($notesUrl, $user, $pass); - $notes = new WebDavNotes($db); - $remoteNotes = $notes->listRemoteNotes(); - if (empty($remoteNotes)) { echo json_encode(['ok' => false, 'error' => 'no remote notes']); exit; } + if ($existingCard) { + // Update existing card + $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); + $db->prepare('UPDATE cards SET title = ?, body = ?, color = ?, tags = ?, updated_at = datetime("now") WHERE id = ?') + ->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $existingCard['id']]); + } else { + // New card + if (!str_contains($body, '#inbox')) $body .= "\n\n#inbox"; + preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); + $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); + $db->prepare('INSERT INTO cards (title, body, color, tags, position, relay_uid) VALUES (?, ?, ?, ?, -1, ?)') + ->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $relayUid]); + } + $pulled++; - // Find working key by testing first encrypted note - $parsed = parse_url($notesUrl); - $base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''); - $workingKey = null; + // Download attachments + if ($relayUid) { + $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid)); + curl_setopt_array($ach, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 30, + ]); + $attResponse = curl_exec($ach); + $attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE); + curl_close($ach); - foreach ($remoteNotes as $testNote) { - $testRes = $client->request('GET', $base . $testNote['href']); - $testBody = $testRes['body'] ?? ''; - if (!str_starts_with($testBody, 'HYPRCRD-ENCRYPTED-V1')) continue; - - $encData = trim(explode("\n", $testBody, 2)[1] ?? ''); - $raw = base64_decode($encData); - if (strlen($raw) < 17) continue; - $iv = substr($raw, 0, 16); - $cipher = substr($raw, 16); - - foreach ($candidateKeys as $tryKey) { - $plain = openssl_decrypt($cipher, 'aes-256-cbc', $tryKey, OPENSSL_RAW_DATA, $iv); - if ($plain !== false && (str_contains($plain, '#') || str_contains($plain, "\n"))) { - $workingKey = $tryKey; - break 2; + if ($attStatus === 200) { + $attachments = json_decode($attResponse, true); + $uploadDir = __DIR__ . '/uploads'; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + foreach ($attachments ?? [] as $att) { + $fn = basename($att['filename']); + if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue; + $dest = $uploadDir . '/' . $fn; + if (file_exists($dest)) continue; + $raw = base64_decode($att['data']); + if (strlen($raw) >= 17) { + $decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); + if ($decrypted !== false) file_put_contents($dest, $decrypted); + } + } } } } - if (!$workingKey) { - echo json_encode(['ok' => false, 'error' => 'could not decrypt — old key not found']); - exit; - } - - if ($workingKey === $currentKey) { - echo json_encode(['ok' => true, 'rekeyed' => 0, 'message' => 'notes already on current key']); - exit; - } - - // Re-encrypt all notes - $count = 0; - foreach ($remoteNotes as $note) { - $res = $client->request('GET', $base . $note['href']); - if ($res['status'] !== 200) continue; - $body = $res['body']; - if (!str_starts_with($body, 'HYPRCRD-ENCRYPTED-V1')) continue; - - $encData = trim(explode("\n", $body, 2)[1] ?? ''); - $raw = base64_decode($encData); - if (strlen($raw) < 17) continue; - $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $workingKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); - if ($plain === false) continue; - - $newIv = random_bytes(16); - $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $currentKey, OPENSSL_RAW_DATA, $newIv); - $client->request('PUT', $base . $note['href'], "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($newIv . $newCipher), ['Content-Type: text/markdown; charset=utf-8']); - $count++; - } - - // Clear and re-pull - $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'"); - $db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'"); - $db->exec("DELETE FROM settings WHERE key = 'prev_password_hash'"); - - echo json_encode(['ok' => true, 'rekeyed' => $count]); - exit; + echo json_encode(['ok' => true, 'pulled' => $pulled]); exit; } - // Force re-sync: clear all notes state and re-pull - if ($route === '/api/notes/reset' && $method === 'POST') { - $deleted = $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'"); - $db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'"); - $notes = new WebDavNotes($db); - $result = $notes->sync(); - $result['reset'] = $deleted; - echo json_encode($result); exit; + // Push a card to relay + if ($route === '/api/relay/push' && $method === 'POST') { + $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); + $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); + if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; } + + $input = json_decode(file_get_contents('php://input'), true); + $cardId = $input['card_id'] ?? null; + if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; } + + $card = $db->prepare('SELECT * FROM cards WHERE id = ?'); + $card->execute([$cardId]); + $card = $card->fetch(); + if (!$card) { http_response_code(404); echo json_encode(['error' => 'card not found']); exit; } + + // Generate relay_uid if not set + $relayUid = $card['relay_uid'] ?? null; + if (!$relayUid) { + $relayUid = bin2hex(random_bytes(16)); + $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]); + } + + $encKey = substr(hash('sha256', $relayKey), 0, 32); + $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); + }; + + $payload = json_encode([ + 'title' => $encryptField($card['title'] ?? ''), + 'body' => $encryptField($card['body'] ?? ''), + 'color' => $card['color'] ?? '#38bdf8', + 'tags' => $card['tags'] ?? '', + 'relay_uid' => $relayUid, + ]); + + $ch = curl_init(rtrim($relayUrl, '/') . '/api/messages'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status === 200 || $status === 201) { + $resData = json_decode($response, true); + if (!empty($resData['id'])) { + $db->prepare("INSERT OR IGNORE INTO relay_pushed (relay_msg_id) VALUES (?)")->execute([$resData['id']]); + } + + // Upload attachments (images referenced in body) + $cardBody = $card['body'] ?? ''; + preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches); + $uploadDir = __DIR__ . '/uploads'; + foreach (array_unique($attMatches[1] ?? []) as $filename) { + $filePath = $uploadDir . '/' . $filename; + if (!file_exists($filePath)) continue; + $raw = file_get_contents($filePath); + $iv = random_bytes(16); + $cipher = openssl_encrypt($raw, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); + $attPayload = json_encode([ + 'relay_uid' => $relayUid, + 'filename' => $filename, + 'data' => base64_encode($iv . $cipher), + ]); + $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments'); + curl_setopt_array($ach, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $attPayload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ach); + curl_close($ach); + } + + echo json_encode(['ok' => true]); exit; + } + echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; @@ -835,7 +1088,7 @@ if (str_starts_with($route, '/api/')) {
-
+
- +
@@ -1466,6 +1743,9 @@ if (str_starts_with($route, '/api/')) { + diff --git a/public/js/app.js b/public/js/app.js index adb0e16..93fd845 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -16,7 +16,7 @@ import { undo, canUndo } from './modules/undo.js'; import { openGallery, closeGallery } from './modules/gallery.js'; import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js'; -import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings } from './modules/caldav.js'; +import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── @@ -29,6 +29,7 @@ window._renderBoard = renderBoard; window._openCalendar = openCalendar; window._triggerSync = triggerSync; window._loadCaldavSettings = loadCaldavSettings; +window._loadRelaySettings = loadRelaySettings; window._renderTagBar = renderTagBar; window._showContextMenu = showContextMenu; window._ungroupCard = ungroupCard; @@ -139,6 +140,22 @@ importFile?.addEventListener('change', async e => { e.target.value = ''; }); +const importLogseq = document.getElementById('import-logseq-file'); +document.getElementById('import-logseq-btn')?.addEventListener('click', () => importLogseq?.click()); +importLogseq?.addEventListener('change', async e => { + const file = e.target.files?.[0]; + if (!file) return; + const form = new FormData(); form.append('file', file); + try { + const res = await fetch('api/import-logseq', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); + const data = JSON.parse(await res.text()); + if (data.error) throw new Error(data.error); + await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); + await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' }); + } catch (err) { console.error('Logseq import failed:', err); } + e.target.value = ''; +}); + // ── Auth ── document.getElementById('logout-btn')?.addEventListener('click', async () => { diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 83eff64..ce47872 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -73,6 +73,31 @@ function clearSelection() { export function getSelectedIds() { return [...selectedIds]; } export { clearSelection }; +// Delete/Backspace deletes selected cards +document.addEventListener('keydown', async e => { + if (!selectedIds.size) return; + if (e.key !== 'Delete' && e.key !== 'Backspace') return; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; + e.preventDefault(); + if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { + const { showDialog } = await import('./dialog.js'); + const ok = await showDialog({ title: 'delete cards', message: `${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''} loeschen?`, danger: true, okLabel: 'delete' }); + if (!ok) return; + } + for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'DELETE' }); + clearSelection(); + await window._reloadAll(); +}); + +// Clear selection on click outside board (header, footer, etc) +document.addEventListener('click', e => { + if (!selectedIds.size) return; + if (board.contains(e.target)) return; + if (selectBar?.contains(e.target)) return; + if (document.querySelector('.bucket-picker')?.contains(e.target)) return; + clearSelection(); +}); + $('select-export')?.addEventListener('click', () => { if (!selectedIds.size) return; window.location.href = 'api/export?ids=' + [...selectedIds].join(','); @@ -89,23 +114,66 @@ $('select-group')?.addEventListener('click', async () => { await window._reloadAll(); }); -$('select-bucket')?.addEventListener('click', async () => { +$('select-bucket')?.addEventListener('click', () => { if (!selectedIds.size) return; - const { showDialog } = await import('./dialog.js'); - const bucketOpts = state.buckets.map(b => b.name); - const name = await showDialog({ title: 'move to bucket', input: true, placeholder: 'bucket name', okLabel: 'move' }); - if (!name) return; - let bucket = state.buckets.find(b => b.name.toLowerCase() === name.trim().toLowerCase()); - if (!bucket) { - const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); - if (created?.id) bucket = created; - } - if (!bucket) return; - for (const id of selectedIds) { - await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } }); - } - clearSelection(); - await window._reloadAll(); + // Remove existing picker + document.querySelector('.bucket-picker')?.remove(); + + const btn = $('select-bucket'); + const rect = btn.getBoundingClientRect(); + const picker = document.createElement('div'); + picker.className = 'bucket-picker'; + picker.style.cssText = `position:fixed;left:${rect.left}px;bottom:${window.innerHeight - rect.top + 4}px;z-index:200;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:260px;display:flex;flex-direction:column;`; + + const list = document.createElement('div'); + list.style.cssText = 'overflow-y:auto;flex:1;'; + state.buckets.forEach(b => { + 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 = `${esc(b.name)}`; + item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; }); + item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; }); + item.addEventListener('click', async () => { + picker.remove(); + for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: b.id } }); + clearSelection(); + await window._reloadAll(); + }); + list.appendChild(item); + }); + picker.appendChild(list); + + const inputWrap = document.createElement('div'); + inputWrap.style.cssText = 'border-top:1px solid var(--t-border);padding:6px;'; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'new bucket...'; + input.style.cssText = 'width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:4px 8px;font-size:11px;font-family:var(--t-font-pixel);border-radius:3px;'; + input.addEventListener('keydown', async e => { + if (e.key !== 'Enter') return; + const name = input.value.trim(); + if (!name) return; + picker.remove(); + let bucket = state.buckets.find(b => b.name.toLowerCase() === name.toLowerCase()); + if (!bucket) { + const created = await api('api/buckets', { method: 'POST', body: { name } }); + if (created?.id) bucket = created; + } + if (!bucket) return; + for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } }); + clearSelection(); + await window._reloadAll(); + }); + inputWrap.appendChild(input); + picker.appendChild(inputWrap); + + document.body.appendChild(picker); + input.focus(); + + const closePicker = e => { if (!picker.contains(e.target) && e.target !== btn) { picker.remove(); cleanup(); } }; + const closeOnEsc = e => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } }; + const cleanup = () => { document.removeEventListener('mousedown', closePicker); document.removeEventListener('keydown', closeOnEsc, true); }; + setTimeout(() => { document.addEventListener('mousedown', closePicker); document.addEventListener('keydown', closeOnEsc, true); }, 0); }); $('select-color')?.addEventListener('click', async () => { @@ -137,6 +205,15 @@ $('select-tag')?.addEventListener('click', async () => { await window._reloadAll(); }); +$('select-unbucket')?.addEventListener('click', async () => { + if (!selectedIds.size) return; + for (const id of selectedIds) { + await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: null } }); + } + clearSelection(); + await window._reloadAll(); +}); + $('select-delete')?.addEventListener('click', async () => { if (!selectedIds.size) return; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { @@ -568,7 +645,11 @@ export function renderBoard() { if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`; // Refresh split preview if open - if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId); + if (boardSplitOpen && boardPreviewCardId) { + const stillExists = state.cards.some(c => c.id == boardPreviewCardId); + if (stillExists) showBoardPreview(boardPreviewCardId); + else { boardPreviewCardId = null; resetBoardPreview(); } + } // Notification banner renderNotifs(); @@ -790,6 +871,18 @@ function bindCardEvents() { window._renderTagBar?.(); renderBoard(); }); + seg.addEventListener('contextmenu', async e => { + e.preventDefault(); + e.stopPropagation(); + const tag = seg.dataset.tag; + const cardEl = seg.closest('[data-id]'); + if (!cardEl) return; + const card = state.cards.find(c => c.id == cardEl.dataset.id); + if (!card) return; + const newBody = (card.body || '').replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), ''); + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } }); + await window._reloadAll?.(); + }); }); board.querySelectorAll('[data-id]').forEach(el => { @@ -997,3 +1090,87 @@ async function handleCardDrop(e, el, targetId) { requestAnimationFrame(() => { card.style.transition = 'transform 0.25s ease'; card.style.transform = ''; }); }); } + +// ── Box select ── + +let boxEl = null; +let boxStart = null; +let boxActive = false; + +board.addEventListener('mousedown', e => { + // Only start on board background (not on cards or interactive elements) + if (e.button !== 0 || e.target !== board) return; + if (e.shiftKey) return; + + const additive = e.ctrlKey || e.metaKey; + const priorSelection = additive ? new Set(selectedIds) : null; + boxStart = { x: e.clientX, y: e.clientY, scrollTop: board.scrollTop }; + boxActive = false; + + const onMove = ev => { + const dx = ev.clientX - boxStart.x; + const dy = ev.clientY - boxStart.y; + if (!boxActive && Math.abs(dx) < 5 && Math.abs(dy) < 5) return; + + if (!boxActive) { + boxActive = true; + if (!additive) clearSelection(); + boxEl = document.createElement('div'); + boxEl.className = 'box-select'; + board.appendChild(boxEl); + } + + const boardRect = board.getBoundingClientRect(); + const scrollDelta = board.scrollTop - boxStart.scrollTop; + const x1 = boxStart.x - boardRect.left; + const y1 = boxStart.y - boardRect.top + board.scrollTop; + const x2 = ev.clientX - boardRect.left; + const y2 = ev.clientY - boardRect.top + board.scrollTop; + + const left = Math.min(x1, x2); + const top = Math.min(y1, y2); + const width = Math.abs(x2 - x1); + const height = Math.abs(y2 - y1); + + boxEl.style.left = left + 'px'; + boxEl.style.top = top + 'px'; + boxEl.style.width = width + 'px'; + boxEl.style.height = height + 'px'; + + // Check intersections + const selRect = { left, top, right: left + width, bottom: top + height }; + board.querySelectorAll('[data-id]').forEach(card => { + const cr = card.getBoundingClientRect(); + const cardRect = { + left: cr.left - boardRect.left, + top: cr.top - boardRect.top + board.scrollTop, + right: cr.right - boardRect.left, + bottom: cr.bottom - boardRect.top + board.scrollTop, + }; + const intersects = !(cardRect.right < selRect.left || cardRect.left > selRect.right || cardRect.bottom < selRect.top || cardRect.top > selRect.bottom); + const id = card.dataset.id; + const wasPrior = priorSelection?.has(id); + if (additive) { + // Toggle: prior selected + hit by box → deselect; not prior + hit → select + const shouldBeSelected = intersects ? !wasPrior : !!wasPrior; + if (shouldBeSelected && !selectedIds.has(id)) toggleSelect(id); + else if (!shouldBeSelected && selectedIds.has(id)) toggleSelect(id); + } else { + if (intersects && !selectedIds.has(id)) toggleSelect(id); + else if (!intersects && selectedIds.has(id)) toggleSelect(id); + } + }); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (boxEl) { boxEl.remove(); boxEl = null; } + if (!boxActive && selectedIds.size) clearSelection(); + boxStart = null; + boxActive = false; + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +}); diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js index 7ea4968..555d799 100644 --- a/public/js/modules/buckets.js +++ b/public/js/modules/buckets.js @@ -22,6 +22,7 @@ async function showBucketCtx(x, y, bucket) { const idx = state.buckets.findIndex(b => b.id == bucket.id); const canLeft = idx > 0; const canRight = idx < state.buckets.length - 1; + const hasCards = state.cards.some(c => c.bucket_id == bucket.id); ctxMenu.innerHTML = `
@@ -30,8 +31,11 @@ async function showBucketCtx(x, y, bucket) {
- - + ${hasCards + ? ` + ` + : `` + }