commit 49e3134758820cfb06a7d5509186fd6cbbcfb545 Author: airent106 Date: Thu Oct 29 10:41:37 2026 -0600 Initial commit: SparkVault Idea Tracker, 64-card Creative Whack Pack & Nano Banana 2.1 Mockup Studio diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ef0a8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +data/ideas.db* +test +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23136af --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +data/ideas.db +data/ideas.db-shm +data/ideas.db-wal +data/mockups/nb-* +data/mockups/mockup-* +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24ebf47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=80 +ENV DATA_DIR=/app/store + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY public/ ./public/ +COPY server/ ./server/ +COPY data/mockups/ ./data/mockups/ + +RUN mkdir -p /app/store/mockups + +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1 + +CMD ["node", "server/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..24d7076 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# SparkVault β€” Idea Tracker, Creative Whack Pack & Nano Banana 2.1 Mockup Studio + +**SparkVault** is a self-hosted full-stack idea pipeline, creative catalyst deck, and visual mockup studio built with Node.js 22 (`node:sqlite`) and Google's **Nano Banana 2.1 (`gemini-nano-banana-2.1`)**. + +## Core Features +- **⚑ Instant Quick Capture (`Ctrl+K`)**: Capture fleeting sparks with inline `#tags`, `@category` (`@tshirt`, `@creative`, `@project`, `@research`, `@spark`), and `!high` excitement boost. +- **πŸ“‹ 5-Stage Pipeline Board & Explorer Grid**: Drag-and-drop ideas across **Spark β†’ Exploring β†’ Building β†’ Shipped β†’ Archived** with composite priority scoring (`0–100`), checklists, Markdown notes, and pros/cons. +- **πŸƒ 64-Card Creative Whack Pack Studio**: + - Full 64-card Roger von Oech deck across **🧭 Explorer (1–16)**, **🎨 Artist (17–32)**, **βš–οΈ Judge (33–48)**, and **βš”οΈ Warrior (49–64)**. + - **Idea Collider**: Collide a random Whack Pack card with an active spark to log breakthroughs or visualize twists. + - **4-Role Creative Spread** & searchable 64-card browser. +- **🍌 Nano Banana 2.1 Visual Mockup Studio (`gemini-nano-banana-2.1`)**: + - Generate photorealistic **T-Shirt Full-Front**, **T-Shirt 5-inch Left-Chest**, **Die-Cut Sticker**, **Poster**, or **Isolated Screenprint Graphic** mockups. + - **Multi-Turn Iterative Image Editing**: Select any existing mockup and pass a natural-language edit instruction to evolve the concept step-by-step. + - **Interactive HTML5 Canvas T-Shirt & Product Compositor**: Instant garment color swatches, Left-Chest vs. Full-Front placement, vector emblems, custom image overlay, and screenprint typography. +- **βš”οΈ Resurfacing & Head-to-Head Arena**: Pairwise idea comparisons and a stale-spark triage deck. diff --git a/data/mockups/seed-sticker-whack.jpg b/data/mockups/seed-sticker-whack.jpg new file mode 100644 index 0000000..2401420 Binary files /dev/null and b/data/mockups/seed-sticker-whack.jpg differ diff --git a/data/mockups/seed-tshirt-espresso.jpg b/data/mockups/seed-tshirt-espresso.jpg new file mode 100644 index 0000000..0e74567 Binary files /dev/null and b/data/mockups/seed-tshirt-espresso.jpg differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6661b7c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + sparkvault: + build: + context: . + dockerfile: Dockerfile + container_name: sparkvault + restart: unless-stopped + volumes: + - /home/chrisf/docker/appdata/sparkvault/data:/app/store + networks: + - t3_proxy + ports: + - "127.0.0.1:8480:80" + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + labels: + - "traefik.enable=true" + - "traefik.http.routers.sparkvault.rule=Host(`sparkvault.vloser.us`)" + - "traefik.http.routers.sparkvault.entrypoints=websecure" + - "traefik.http.routers.sparkvault.tls.certresolver=letsencrypt" + - "traefik.http.services.sparkvault.loadbalancer.server.port=80" + +networks: + t3_proxy: + external: true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ed28b1c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,479 @@ +{ + "name": "sparkvault-idea-tracker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sparkvault-idea-tracker", + "version": "1.0.0", + "dependencies": { + "@google/genai": "^2.29.1" + } + }, + "node_modules/@google/genai": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.32.0.tgz", + "integrity": "sha512-60c79uXpR0a47bmGqDxbXEBuQUalNmwo5mx5kqb91jIW4FoqpsLSt86DeVhRhtH5+s/16Wqxt3sXzFQn2U1OHQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8295fb --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "sparkvault-idea-tracker", + "version": "1.0.0", + "description": "Full-stack Idea Tracker with 64-card Creative Whack Pack & Nano Banana 2.1 Visual Mockup Studio", + "type": "module", + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "node --test test/api.test.js" + }, + "dependencies": { + "@google/genai": "^2.29.1" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..fcdca81 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1925 @@ +// SparkVault β€” Frontend Application Logic +// Pipeline Board, Rich Workbench, 64-Card Creative Whack Pack & Nano Banana 2.1 Mockup Studio + +const state = { + ideas: [], + stats: { total: 0, statusCounts: {}, totalMockups: 0, totalWhacks: 0, tags: [] }, + whackCards: [], + whackSuits: {}, + currentView: "board", + gridStatusFilter: "all", + filters: { + search: "", + category: "all", + tag: "all", + hasMockup: false, + sort: "score" + }, + // Whack Studio state + whackSubmode: "collider", + colliderCard: null, + colliderIdea: null, + spreadCards: [], + deckSuitFilter: "all", + deckSearch: "", + // Quick Banner Whack Card + quickBannerCard: null, + // Review & Arena state + reviewMode: "arena", + triageQueue: [], + triageIndex: 0, + arenaPair: [], + // Active Drawer Idea + activeIdeaId: null, + activeIdea: null, + drawerTab: "details", + drawerWhackCard: null, + // Nano Banana Studio state + nbGarmentColor: "#F5E8D3", + nbGarmentColorName: "Vintage Cream", + nbEditParentMockup: null, + uploadedOverlayImage: null, + settings: { + hasApiKey: false, + apiKeySource: "none", + maskedKey: "", + nanoBananaModel: "gemini-nano-banana-2.1" + } +}; + +const CATEGORY_LABELS = { + creative: "🎨 Creative / Merch", + project: "πŸ› οΈ Project / App", + spark: "⚑ Quick Spark", + research: "πŸ”¬ Research" +}; + +const NEXT_STAGE_MAP = { + spark: "exploring", + exploring: "building", + building: "shipped", + shipped: "shipped", + archived: "spark" +}; + +function escapeHtml(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSimpleMarkdown(md) { + if (!md) return '

No notes yet.

'; + const lines = String(md).split("\n"); + const html = lines + .map((line) => { + const trimmed = line.trim(); + if (trimmed.startsWith("### ")) return `

${escapeHtml(trimmed.slice(4))}

`; + if (trimmed.startsWith("## ")) return `

${escapeHtml(trimmed.slice(3))}

`; + if (trimmed.startsWith("# ")) return `

${escapeHtml(trimmed.slice(2))}

`; + if (trimmed.startsWith("- ")) return `
  • ${formatInlineMd(trimmed.slice(2))}
  • `; + if (!trimmed) return "
    "; + return `

    ${formatInlineMd(trimmed)}

    `; + }) + .join(""); + return html; +} + +function formatInlineMd(text) { + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +function showToast(message) { + const container = document.getElementById("toast-container"); + const el = document.createElement("div"); + el.className = "toast"; + el.textContent = message; + container.appendChild(el); + setTimeout(() => el.remove(), 3400); +} + +async function apiFetch(url, options = {}) { + const res = await fetch(url, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `Request failed (${res.status})`); + err.code = data.code; + throw err; + } + return data; +} + +// --- INITIALIZATION --- +async function initApp() { + bindGlobalEvents(); + await Promise.all([loadSettings(), loadWhackCatalog(), loadIdeas()]); + renderCompositorCanvas(); +} + +async function loadSettings() { + try { + const data = await apiFetch("/api/settings"); + state.settings = data; + const dot = document.getElementById("api-key-status-dot"); + if (dot) { + dot.classList.toggle("online", Boolean(data.hasApiKey)); + } + const keyStatus = document.getElementById("settings-key-status"); + if (keyStatus) { + keyStatus.textContent = data.hasApiKey + ? `Active key configured (${data.apiKeySource}: ${data.maskedKey})` + : "No key stored yet β€” paste your Gemini API key to enable live Nano Banana 2.1 generation."; + } + const modelSel = document.getElementById("settings-model-select"); + if (modelSel && data.nanoBananaModel) { + modelSel.value = data.nanoBananaModel; + } + } catch (err) { + console.warn("Failed to load settings:", err); + } +} + +async function loadWhackCatalog() { + const data = await apiFetch("/api/whack-cards"); + state.whackCards = data.cards || []; + state.whackSuits = data.suits || {}; + populateWhackTwistSelect(); + renderDeckBrowser(); +} + +function populateWhackTwistSelect() { + const sel = document.getElementById("nb-whack-twist-select"); + if (!sel) return; + sel.innerHTML = `` + + state.whackCards + .map((c) => ``) + .join(""); +} + +async function loadIdeas() { + const params = new URLSearchParams(); + if (state.filters.search) params.set("search", state.filters.search); + if (state.filters.category !== "all") params.set("category", state.filters.category); + if (state.filters.tag !== "all") params.set("tag", state.filters.tag); + if (state.filters.hasMockup) params.set("hasMockup", "true"); + params.set("sort", state.filters.sort); + + const data = await apiFetch(`/api/ideas?${params.toString()}`); + state.ideas = data.ideas || []; + state.stats = data.stats || state.stats; + + updateFilterTagOptions(); + document.getElementById("stat-mockups-count").textContent = state.stats.totalMockups || 0; + + renderKanbanBoard(); + renderExplorerGrid(); + populateColliderIdeaSelect(); + + if (state.activeIdeaId) { + const refreshed = state.ideas.find((i) => i.id === state.activeIdeaId); + if (refreshed) { + state.activeIdea = refreshed; + renderDrawerContent(); + } + } +} + +function updateFilterTagOptions() { + const sel = document.getElementById("filter-tag"); + if (!sel) return; + const current = state.filters.tag; + const optionsHtml = + `` + + (state.stats.tags || []) + .map((t) => ``) + .join(""); + sel.innerHTML = optionsHtml; + if ([...sel.options].some((o) => o.value === current)) { + sel.value = current; + } +} + +// --- RENDER KANBAN BOARD --- +function renderKanbanBoard() { + const statuses = ["spark", "exploring", "building", "shipped", "archived"]; + for (const st of statuses) { + const colEl = document.getElementById(`col-${st}`); + const countEl = document.getElementById(`count-${st}`); + const list = state.ideas.filter((i) => i.status === st); + if (countEl) countEl.textContent = list.length; + if (!colEl) continue; + + if (list.length === 0) { + colEl.innerHTML = `
    Drop ideas here
    `; + continue; + } + + colEl.innerHTML = list.map((idea) => renderIdeaCardHtml(idea)).join(""); + bindIdeaCardEvents(colEl); + } +} + +function renderIdeaCardHtml(idea) { + const checklistTotal = idea.checklist?.length || 0; + const checklistDone = (idea.checklist || []).filter((c) => c.done).length; + const whackCount = idea.whack_history?.length || 0; + const nextStage = NEXT_STAGE_MAP[idea.status]; + + return ` +
    + ${ + idea.cover_mockup_url + ? `${escapeHtml(idea.title)}` + : "" + } +
    + ${CATEGORY_LABELS[idea.category] || idea.category} + ⚑ ${idea.composite_score} +
    +

    ${idea.pinned ? "πŸ“Œ " : ""}${escapeHtml(idea.title)}

    + ${idea.elevator_pitch ? `

    ${escapeHtml(idea.elevator_pitch)}

    ` : ""} + ${ + idea.tags.length > 0 + ? `
    ${idea.tags.map((t) => `#${escapeHtml(t)}`).join("")}
    ` + : "" + } + +
    + `; +} + +function bindIdeaCardEvents(container) { + container.querySelectorAll(".idea-card").forEach((cardEl) => { + const id = Number(cardEl.dataset.ideaId); + + cardEl.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + openIdeaDrawer(id, "details"); + }); + + cardEl.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("text/plain", String(id)); + }); + }); + + container.querySelectorAll(".btn-card-mockup").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openIdeaDrawer(Number(btn.dataset.ideaId), "mockup"); + }); + }); + + container.querySelectorAll(".btn-card-whack").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openIdeaDrawer(Number(btn.dataset.ideaId), "whack"); + }); + }); + + container.querySelectorAll(".btn-card-advance").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const id = Number(btn.dataset.ideaId); + const next = btn.dataset.next; + await apiFetch(`/api/ideas/${id}`, { + method: "PATCH", + body: JSON.stringify({ status: next }) + }); + showToast(`Moved idea to ${next.toUpperCase()}`); + await loadIdeas(); + }); + }); +} + +// --- RENDER EXPLORER GRID --- +function renderExplorerGrid() { + const container = document.getElementById("explorer-grid"); + if (!container) return; + let items = state.ideas; + if (state.gridStatusFilter !== "all") { + items = items.filter((i) => i.status === state.gridStatusFilter); + } + if (items.length === 0) { + container.innerHTML = `
    No matching ideas found. Capture a spark above!
    `; + return; + } + container.innerHTML = items.map((idea) => renderIdeaCardHtml(idea)).join(""); + bindIdeaCardEvents(container); +} + +// --- CREATIVE WHACK PACK STUDIO --- +function populateColliderIdeaSelect() { + const sel = document.getElementById("collider-idea-select"); + if (!sel) return; + const active = state.ideas.filter((i) => i.status !== "archived"); + const currentVal = sel.value; + sel.innerHTML = + `` + + active.map((i) => ``).join(""); + if (currentVal && [...sel.options].some((o) => o.value === currentVal)) { + sel.value = currentVal; + } +} + +async function dealIdeaCollider({ forceRandomIdea = false } = {}) { + const suit = document.getElementById("collider-suit-select")?.value || ""; + const selectedIdeaId = forceRandomIdea ? "" : document.getElementById("collider-idea-select")?.value || ""; + + const params = new URLSearchParams({ mode: "collider" }); + if (suit) params.set("suit", suit); + if (selectedIdeaId) params.set("ideaId", selectedIdeaId); + + const data = await apiFetch(`/api/whack-cards/draw?${params.toString()}`); + state.colliderCard = data.card; + state.colliderIdea = data.idea; + renderColliderStage(); +} + +function renderColliderStage() { + const stage = document.getElementById("collider-stage"); + if (!stage || !state.colliderCard) return; + + const card = state.colliderCard; + const idea = state.colliderIdea; + + stage.innerHTML = ` +
    +
    + ${card.suitLabel} β€’ Card #${card.number} + +
    + ${escapeHtml(card.title)} +

    ${escapeHtml(card.title)}

    +

    ${escapeHtml(card.roleDescription)}

    +
    + +
    +
    + COLLIDING WITH YOUR IDEA: + ${ + idea + ? `

    ${escapeHtml(idea.title)} Stage: ${idea.status.toUpperCase()}

    +

    ${escapeHtml(idea.elevator_pitch || "No elevator pitch yet.")}

    ` + : `

    Freeform Brainstorm (No active idea selected)

    ` + } +
    + +
    +
    GUIDING WHACK QUESTION (CARD #${card.number}):
    + ${escapeHtml(card.promptQuestion)} +
    + +
    + + +
    + +
    + ${ + idea + ? ` + + ` + : "" + } + +
    +
    + `; + + document.getElementById("img-collider-card")?.addEventListener("click", () => openCardLightbox(card)); + document.getElementById("btn-zoom-collider-card")?.addEventListener("click", () => openCardLightbox(card)); + + document.getElementById("btn-save-collider-whack")?.addEventListener("click", async () => { + const reflection = document.getElementById("collider-reflection-input")?.value.trim(); + if (!reflection) { + showToast("Write a quick reflection or breakthrough first!"); + return; + } + const addChecklistItem = document.getElementById("collider-add-checklist")?.checked; + await apiFetch(`/api/ideas/${idea.id}/whack`, { + method: "POST", + body: JSON.stringify({ + cardNumber: card.number, + reflection, + addChecklistItem + }) + }); + showToast(`Logged Card #${card.number} breakthrough onto "${idea.title}"!`); + document.getElementById("collider-reflection-input").value = ""; + await Promise.all([loadIdeas(), loadWhackCatalog()]); + }); + + document.getElementById("btn-collider-to-mockup")?.addEventListener("click", async () => { + const reflection = document.getElementById("collider-reflection-input")?.value.trim(); + await openIdeaDrawer(idea.id, "mockup"); + const twistSel = document.getElementById("nb-whack-twist-select"); + if (twistSel) twistSel.value = String(card.number); + const promptEl = document.getElementById("nb-prompt-input"); + if (promptEl) { + promptEl.value = reflection + ? `${idea.title} β€” ${reflection} (Inspired by Card #${card.number}: ${card.title})` + : `${idea.title} β€” reimagined using "${card.title}": ${card.promptQuestion}`; + } + }); + + document.getElementById("btn-spawn-from-collider")?.addEventListener("click", async () => { + const reflection = document.getElementById("collider-reflection-input")?.value.trim(); + const title = reflection + ? reflection.split("\n")[0].slice(0, 90) + : `Spark from Card #${card.number}: ${card.title}`; + const created = await apiFetch("/api/ideas", { + method: "POST", + body: JSON.stringify({ + title, + elevator_pitch: `Inspired by Creative Whack Pack Card #${card.number} (${card.title}): ${card.promptQuestion}`, + inspired_by_card: card.number, + tags: ["whack-spark", card.suit] + }) + }); + showToast(`Created new Spark: "${created.idea.title}"`); + await Promise.all([loadIdeas(), loadWhackCatalog()]); + openIdeaDrawer(created.idea.id, "details"); + }); +} + +async function dealFourRoleSpread() { + const data = await apiFetch("/api/whack-cards/draw?mode=spread"); + state.spreadCards = data.cards || []; + const grid = document.getElementById("spread-grid"); + if (!grid) return; + + grid.innerHTML = state.spreadCards + .map( + (card) => ` +
    +
    + ${card.suitLabel} (#${card.number}) +
    + ${escapeHtml(card.title)} +

    ${escapeHtml(card.title)}

    +

    ${escapeHtml(card.promptQuestion)}

    +
    + + +
    +
    + ` + ) + .join(""); + + grid.querySelectorAll("img[data-card-num]").forEach((img) => { + img.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(img.dataset.cardNum)); + if (c) openCardLightbox(c); + }); + }); + + grid.querySelectorAll(".btn-spread-collide").forEach((btn) => { + btn.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum)); + if (!c) return; + state.colliderCard = c; + if (!state.colliderIdea && state.ideas.length > 0) { + state.colliderIdea = state.ideas[0]; + } + switchWhackSubmode("collider"); + renderColliderStage(); + }); + }); + + grid.querySelectorAll(".btn-spread-spawn").forEach((btn) => { + btn.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum)); + if (c) showQuickWhackBanner(c); + }); + }); +} + +function renderDeckBrowser() { + const grid = document.getElementById("deck-cards-grid"); + if (!grid) return; + + let list = state.whackCards; + if (state.deckSuitFilter !== "all") { + list = list.filter((c) => c.suit === state.deckSuitFilter); + } + if (state.deckSearch.trim()) { + const q = state.deckSearch.trim().toLowerCase(); + list = list.filter( + (c) => + c.title.toLowerCase().includes(q) || + c.promptQuestion.toLowerCase().includes(q) || + String(c.number) === q + ); + } + + grid.innerHTML = list + .map( + (card) => ` +
    +
    + #${card.number} ${card.suitLabel} + ${card.usageCount > 0 ? `Used ${card.usageCount}x` : ""} +
    + ${escapeHtml(card.title)} + ${escapeHtml(card.title)} +

    ${escapeHtml(card.promptQuestion)}

    +
    + + +
    +
    + ` + ) + .join(""); + + grid.querySelectorAll("img[data-card-num]").forEach((img) => { + img.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(img.dataset.cardNum)); + if (c) openCardLightbox(c); + }); + }); + + grid.querySelectorAll(".btn-deck-collide").forEach((btn) => { + btn.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum)); + if (!c) return; + state.colliderCard = c; + if (!state.colliderIdea && state.ideas.length > 0) { + state.colliderIdea = state.ideas[0]; + } + switchWhackSubmode("collider"); + renderColliderStage(); + }); + }); + + grid.querySelectorAll(".btn-deck-spark").forEach((btn) => { + btn.addEventListener("click", () => { + const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum)); + if (c) showQuickWhackBanner(c); + }); + }); +} + +// --- REVIEW & HEAD-TO-HEAD ARENA --- +async function loadReviewSession() { + const data = await apiFetch("/api/review/next"); + state.triageQueue = data.triageQueue || []; + state.triageIndex = 0; + state.arenaPair = data.arenaPair || []; + renderArenaStage(); + renderTriageStage(); +} + +function renderArenaStage() { + const container = document.getElementById("arena-stage"); + if (!container) return; + + if (state.arenaPair.length < 2) { + container.innerHTML = `
    Need at least 2 active ideas for Head-to-Head Arena. Capture more sparks above!
    `; + return; + } + + const [left, right] = state.arenaPair; + container.innerHTML = ` + ${renderArenaFighterHtml(left, right.id, "left")} +
    VS
    + ${renderArenaFighterHtml(right, left.id, "right")} + `; + + container.querySelectorAll(".btn-arena-win").forEach((btn) => { + btn.addEventListener("click", async () => { + const winnerId = Number(btn.dataset.winner); + const loserId = Number(btn.dataset.loser); + const res = await apiFetch("/api/review/arena", { + method: "POST", + body: JSON.stringify({ winnerId, loserId }) + }); + showToast(`πŸ† "${res.winner.title}" wins the round! (Score: ${res.winner.composite_score})`); + await Promise.all([loadIdeas(), loadReviewSession()]); + }); + }); + + container.querySelectorAll(".btn-arena-open").forEach((btn) => { + btn.addEventListener("click", () => openIdeaDrawer(Number(btn.dataset.id), "details")); + }); +} + +function renderArenaFighterHtml(idea, opponentId) { + return ` +
    +
    +
    + ${CATEGORY_LABELS[idea.category] || idea.category} + ⚑ Score ${idea.composite_score} β€’ πŸ† ${idea.arena_wins}W +
    + ${ + idea.cover_mockup_url + ? `${escapeHtml(idea.title)}` + : "" + } +

    ${escapeHtml(idea.title)}

    +

    + ${escapeHtml(idea.elevator_pitch || idea.notes_markdown.slice(0, 140) || "No description yet.")} +

    +
    + ${idea.tags.map((t) => `#${escapeHtml(t)}`).join("")} +
    +
    +
    + + +
    +
    + `; +} + +function renderTriageStage() { + const container = document.getElementById("triage-stage"); + if (!container) return; + + const item = state.triageQueue[state.triageIndex]; + if (!item) { + container.innerHTML = `

    πŸŽ‰ All Caught Up!

    You've reviewed every active idea in the triage queue.

    `; + return; + } + + const nextStage = NEXT_STAGE_MAP[item.status]; + container.innerHTML = ` +
    +
    + Stage: ${item.status.toUpperCase()} β€’ ${CATEGORY_LABELS[item.category] || item.category} + Card ${state.triageIndex + 1} of ${state.triageQueue.length} +
    + ${ + item.cover_mockup_url + ? `${escapeHtml(item.title)}` + : "" + } +

    ${escapeHtml(item.title)}

    +

    ${escapeHtml(item.elevator_pitch || "No elevator pitch yet β€” open Workbench or deal a Whack card to flesh it out.")}

    +
    + + + + + +
    +
    + `; + + document.getElementById("btn-triage-promote")?.addEventListener("click", async () => { + await apiFetch(`/api/ideas/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ status: nextStage, last_reviewed_at: new Date().toISOString() }) + }); + showToast(`Promoted "${item.title}" to ${nextStage.toUpperCase()}`); + state.triageIndex++; + await loadIdeas(); + renderTriageStage(); + }); + + document.getElementById("btn-triage-mockup")?.addEventListener("click", () => { + openIdeaDrawer(item.id, "mockup"); + }); + + document.getElementById("btn-triage-whack")?.addEventListener("click", () => { + openIdeaDrawer(item.id, "whack"); + }); + + document.getElementById("btn-triage-keep")?.addEventListener("click", async () => { + await apiFetch(`/api/ideas/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ last_reviewed_at: new Date().toISOString() }) + }); + state.triageIndex++; + renderTriageStage(); + }); + + document.getElementById("btn-triage-archive")?.addEventListener("click", async () => { + await apiFetch(`/api/ideas/${item.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "archived", last_reviewed_at: new Date().toISOString() }) + }); + showToast(`Archived "${item.title}"`); + state.triageIndex++; + await loadIdeas(); + renderTriageStage(); + }); +} + +// --- SLIDE-OVER RICH WORKBENCH & NANO BANANA STUDIO --- +async function openIdeaDrawer(id, initialTab = "details") { + const data = await apiFetch(`/api/ideas/${id}`); + state.activeIdeaId = id; + state.activeIdea = data.idea; + state.nbEditParentMockup = null; + + document.getElementById("drawer-backdrop").classList.remove("hidden"); + document.getElementById("idea-drawer").classList.remove("hidden"); + + switchDrawerTab(initialTab); + renderDrawerContent(); + + // Pre-populate compositor text from idea title + const topInput = document.getElementById("comp-top-text"); + const bottomInput = document.getElementById("comp-bottom-text"); + if (topInput && !topInput.value) { + topInput.value = state.activeIdea.title.slice(0, 26).toUpperCase(); + } + if (bottomInput && !bottomInput.value) { + bottomInput.value = state.activeIdea.tags[0] ? `#${state.activeIdea.tags[0].toUpperCase()}` : "SPARKVAULT STUDIO"; + } + renderCompositorCanvas(); + + if (initialTab === "whack" && !state.drawerWhackCard) { + await drawCardForActiveIdea(); + } +} + +function closeIdeaDrawer() { + state.activeIdeaId = null; + state.activeIdea = null; + document.getElementById("drawer-backdrop").classList.add("hidden"); + document.getElementById("idea-drawer").classList.add("hidden"); +} + +function switchDrawerTab(tabName) { + state.drawerTab = tabName; + document.querySelectorAll(".drawer-tab").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.drawerTab === tabName); + }); + ["details", "mockup", "whack"].forEach((t) => { + const panel = document.getElementById(`drawer-tab-${t}`); + if (panel) panel.classList.toggle("hidden", t !== tabName); + }); + if (tabName === "mockup") { + renderCompositorCanvas(); + } +} + +function renderDrawerContent() { + const idea = state.activeIdea; + if (!idea) return; + + document.getElementById("drawer-title-input").value = idea.title; + document.getElementById("drawer-status-select").value = idea.status; + document.getElementById("drawer-category-select").value = idea.category; + document.getElementById("drawer-pin-btn").textContent = idea.pinned ? "πŸ“Œ Pinned" : "πŸ“Œ Pin"; + document.getElementById("drawer-score-badge").textContent = `⚑ Score: ${idea.composite_score}`; + document.getElementById("drawer-pitch-input").value = idea.elevator_pitch || ""; + document.getElementById("drawer-notes-input").value = idea.notes_markdown || ""; + document.getElementById("drawer-notes-preview").innerHTML = renderSimpleMarkdown(idea.notes_markdown); + + // Sliders + document.getElementById("slider-excitement").value = idea.rating_excitement; + document.getElementById("val-excitement").textContent = idea.rating_excitement; + document.getElementById("slider-impact").value = idea.rating_impact; + document.getElementById("val-impact").textContent = idea.rating_impact; + document.getElementById("slider-ease").value = idea.rating_ease; + document.getElementById("val-ease").textContent = idea.rating_ease; + + // Counts + document.getElementById("drawer-mockup-count").textContent = idea.mockups.length; + document.getElementById("drawer-whack-count").textContent = idea.whack_history.length; + + // Tags + const tagsEl = document.getElementById("drawer-tags-list"); + tagsEl.innerHTML = idea.tags + .map( + (t) => + `#${escapeHtml(t)} ` + ) + .join(""); + tagsEl.querySelectorAll("button[data-remove-tag]").forEach((btn) => { + btn.addEventListener("click", async () => { + const nextTags = idea.tags.filter((x) => x !== btn.dataset.removeTag); + await saveActiveIdeaPatch({ tags: nextTags }); + }); + }); + + // Checklist + const chkContainer = document.getElementById("drawer-checklist-items"); + const doneCount = idea.checklist.filter((c) => c.done).length; + document.getElementById("drawer-checklist-progress").textContent = `${doneCount}/${idea.checklist.length} completed`; + chkContainer.innerHTML = idea.checklist + .map( + (item) => ` +
    + + +
    + ` + ) + .join(""); + + chkContainer.querySelectorAll("input[data-chk-id]").forEach((cb) => { + cb.addEventListener("change", async () => { + const next = idea.checklist.map((c) => (c.id === cb.dataset.chkId ? { ...c, done: cb.checked } : c)); + await saveActiveIdeaPatch({ checklist: next }); + }); + }); + + chkContainer.querySelectorAll("button[data-del-chk]").forEach((btn) => { + btn.addEventListener("click", async () => { + const next = idea.checklist.filter((c) => c.id !== btn.dataset.delChk); + await saveActiveIdeaPatch({ checklist: next }); + }); + }); + + // Pros & Cons + renderBulletList("drawer-pros-list", idea.pros, "pros"); + renderBulletList("drawer-cons-list", idea.cons, "cons"); + + // Links + const linksEl = document.getElementById("drawer-links-list"); + linksEl.innerHTML = idea.links + .map( + (l) => ` +
    + ${escapeHtml(l.title || l.url)} + +
    + ` + ) + .join(""); + linksEl.querySelectorAll("button[data-del-link]").forEach((btn) => { + btn.addEventListener("click", async () => { + const next = idea.links.filter((l) => l.id !== btn.dataset.delLink); + await saveActiveIdeaPatch({ links: next }); + }); + }); + + // Mockup Gallery + renderDrawerMockupGallery(idea); + + // Whack History + renderDrawerWhackHistory(idea); +} + +function renderBulletList(elementId, items, fieldName) { + const ul = document.getElementById(elementId); + if (!ul) return; + ul.innerHTML = items + .map( + (txt, idx) => ` +
  • + ${escapeHtml(txt)} + +
  • + ` + ) + .join(""); + ul.querySelectorAll("button[data-del-idx]").forEach((btn) => { + btn.addEventListener("click", async () => { + const idx = Number(btn.dataset.delIdx); + const next = items.filter((_, i) => i !== idx); + await saveActiveIdeaPatch({ [fieldName]: next }); + }); + }); +} + +async function saveActiveIdeaPatch(patch) { + if (!state.activeIdeaId) return; + const res = await apiFetch(`/api/ideas/${state.activeIdeaId}`, { + method: "PATCH", + body: JSON.stringify(patch) + }); + state.activeIdea = res.idea; + await loadIdeas(); +} + +// --- NANO BANANA MOCKUP GALLERY & ITERATIVE EDITING --- +function renderDrawerMockupGallery(idea) { + const container = document.getElementById("drawer-mockup-gallery"); + if (!container) return; + + if (!idea.mockups || idea.mockups.length === 0) { + container.innerHTML = `

    No mockups saved for this idea yet. Generate one with 🍌 Nano Banana 2.1 or save a layout from the T-Shirt Compositor!

    `; + return; + } + + container.innerHTML = idea.mockups + .map((m) => { + const isCover = idea.cover_mockup_url === m.imageUrl; + return ` + + `; + }) + .join(""); + + container.querySelectorAll("img[data-zoom-url]").forEach((img) => { + img.addEventListener("click", () => { + openImageLightbox(img.dataset.zoomUrl, img.alt); + }); + }); + + container.querySelectorAll(".btn-mk-refine").forEach((btn) => { + btn.addEventListener("click", () => { + const mk = idea.mockups.find((x) => x.id === btn.dataset.mkId); + if (!mk) return; + state.nbEditParentMockup = mk; + document.getElementById("nb-edit-mode-box").classList.remove("hidden"); + document.getElementById("nb-edit-target-label").textContent = mk.title; + document.getElementById("nb-edit-instruction-input").focus(); + showToast(`Iterative Edit Mode enabled for "${mk.title}"`); + }); + }); + + container.querySelectorAll(".btn-mk-load-canvas").forEach((btn) => { + btn.addEventListener("click", () => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + state.uploadedOverlayImage = img; + renderCompositorCanvas(); + showToast("Loaded artwork onto the Interactive T-Shirt Canvas!"); + }; + img.src = btn.dataset.mkUrl; + }); + }); + + container.querySelectorAll(".btn-mk-set-cover").forEach((btn) => { + btn.addEventListener("click", async () => { + await apiFetch(`/api/ideas/${idea.id}/mockups/${btn.dataset.mkId}/cover`, { method: "PATCH" }); + showToast("Updated card cover thumbnail!"); + await loadIdeas(); + }); + }); + + container.querySelectorAll(".btn-mk-delete").forEach((btn) => { + btn.addEventListener("click", async () => { + await apiFetch(`/api/ideas/${idea.id}/mockups/${btn.dataset.mkId}`, { method: "DELETE" }); + showToast("Deleted mockup variation"); + await loadIdeas(); + }); + }); +} + +// --- INTERACTIVE HTML5 CANVAS T-SHIRT & PRODUCT COMPOSITOR --- +function renderCompositorCanvas() { + const canvas = document.getElementById("mockup-compositor-canvas"); + if (!canvas) return; + const ctx = canvas.getContext("2d"); + const W = canvas.width; + const H = canvas.height; + + const surface = document.getElementById("comp-surface-select")?.value || "tshirt"; + const placement = document.getElementById("comp-placement-select")?.value || "center-chest"; + const emblem = document.getElementById("comp-emblem-select")?.value || "mountains"; + const topText = (document.getElementById("comp-top-text")?.value || "HIGH ALTITUDE CLUB").toUpperCase(); + const bottomText = (document.getElementById("comp-bottom-text")?.value || "EST. 1984 β€’ COLORADO").toUpperCase(); + const inkColor = document.getElementById("comp-ink-color")?.value || "#BC4921"; + const garmentHex = state.nbGarmentColor || "#F5E8D3"; + + // Studio background + const bgGrad = ctx.createRadialGradient(W / 2, H / 2, 60, W / 2, H / 2, W * 0.65); + bgGrad.addColorStop(0, "#232938"); + bgGrad.addColorStop(1, "#11141c"); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, W, H); + + if (surface === "tshirt") { + drawTShirtSilhouette(ctx, W, H, garmentHex); + } else if (surface === "sticker") { + drawStickerSurface(ctx, W, H, garmentHex); + } else { + drawPosterSurface(ctx, W, H, garmentHex); + } + + // Determine artwork bounding zone based on placement + let artX = W / 2; + let artY = H * 0.44; + let artScale = 1.0; + + if (surface === "tshirt") { + if (placement === "left-chest") { + artX = W * 0.61; // Viewer's right = wearer's left chest pocket zone + artY = H * 0.36; + artScale = 0.46; + } else if (placement === "oversized") { + artX = W / 2; + artY = H * 0.46; + artScale = 1.22; + } + } + + ctx.save(); + ctx.translate(artX, artY); + ctx.scale(artScale, artScale); + + // Draw optional uploaded or selected image + if (state.uploadedOverlayImage) { + const img = state.uploadedOverlayImage; + const maxDim = 210; + const ratio = Math.min(maxDim / img.width, maxDim / img.height); + const dw = img.width * ratio; + const dh = img.height * ratio; + ctx.drawImage(img, -dw / 2, -dh / 2 - 10, dw, dh); + } else if (emblem !== "none") { + drawVectorEmblem(ctx, emblem, inkColor); + } + + // Draw Screenprint Typography + ctx.fillStyle = inkColor; + ctx.textAlign = "center"; + + if (topText) { + ctx.font = "900 26px Georgia, serif"; + ctx.fillText(topText, 0, -96); + } + if (bottomText) { + ctx.font = "700 16px 'Courier New', monospace"; + ctx.fillText(bottomText, 0, 108); + } + + // Decorative vintage rule lines + ctx.strokeStyle = inkColor; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(-110, 82); + ctx.lineTo(110, 82); + ctx.stroke(); + + ctx.restore(); +} + +function drawTShirtSilhouette(ctx, W, H, garmentHex) { + ctx.save(); + ctx.shadowColor = "rgba(0, 0, 0, 0.55)"; + ctx.shadowBlur = 32; + ctx.shadowOffsetY = 14; + + // Classic Crewneck Tee Vector Path + ctx.beginPath(); + ctx.moveTo(225, 95); // Left shoulder neck + ctx.quadraticCurveTo(340, 132, 455, 95); // Collar front curve + ctx.lineTo(545, 130); // Right shoulder + ctx.lineTo(625, 255); // Right outer sleeve + ctx.lineTo(545, 305); // Right sleeve hem + ctx.lineTo(500, 235); // Right armpit + ctx.lineTo(500, 595); // Right torso hem + ctx.lineTo(180, 595); // Bottom hem + ctx.lineTo(180, 235); // Left armpit + ctx.lineTo(135, 305); // Left sleeve hem + ctx.lineTo(55, 255); // Left outer sleeve + ctx.lineTo(135, 130); // Left shoulder + ctx.closePath(); + + ctx.fillStyle = garmentHex; + ctx.fill(); + + // Collar rib & subtle fabric shading + ctx.shadowColor = "transparent"; + ctx.strokeStyle = "rgba(0,0,0,0.18)"; + ctx.lineWidth = 3; + ctx.stroke(); + + // Back neck interior shadow + ctx.beginPath(); + ctx.moveTo(228, 96); + ctx.quadraticCurveTo(340, 114, 452, 96); + ctx.quadraticCurveTo(340, 136, 228, 96); + ctx.fillStyle = "rgba(0,0,0,0.14)"; + ctx.fill(); + + // Ribbed collar seam + ctx.beginPath(); + ctx.moveTo(215, 102); + ctx.quadraticCurveTo(340, 148, 465, 102); + ctx.strokeStyle = "rgba(0,0,0,0.15)"; + ctx.lineWidth = 4; + ctx.stroke(); + + ctx.restore(); +} + +function drawStickerSurface(ctx, W, H, fillHex) { + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,0.6)"; + ctx.shadowBlur = 28; + // White die-cut vinyl border + ctx.beginPath(); + ctx.arc(W / 2, H * 0.45, 215, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + + ctx.shadowColor = "transparent"; + ctx.beginPath(); + ctx.arc(W / 2, H * 0.45, 198, 0, Math.PI * 2); + ctx.fillStyle = fillHex; + ctx.fill(); + ctx.lineWidth = 4; + ctx.strokeStyle = "#222"; + ctx.stroke(); + ctx.restore(); +} + +function drawPosterSurface(ctx, W, H, fillHex) { + ctx.save(); + ctx.shadowColor = "rgba(0,0,0,0.6)"; + ctx.shadowBlur = 30; + ctx.fillStyle = "#1c1917"; + ctx.fillRect(145, 65, 390, 540); + ctx.shadowColor = "transparent"; + ctx.fillStyle = fillHex; + ctx.fillRect(165, 85, 350, 500); + ctx.restore(); +} + +function drawVectorEmblem(ctx, emblem, inkColor) { + ctx.save(); + ctx.strokeStyle = inkColor; + ctx.fillStyle = inkColor; + ctx.lineWidth = 4; + + // Outer badge ring + ctx.beginPath(); + ctx.arc(0, -8, 72, 0, Math.PI * 2); + ctx.stroke(); + + if (emblem === "mountains") { + // Sun + ctx.beginPath(); + ctx.arc(22, -36, 14, 0, Math.PI * 2); + ctx.fillStyle = "#EFA51C"; + ctx.fill(); + // Peaks + ctx.fillStyle = inkColor; + ctx.beginPath(); + ctx.moveTo(-56, 36); + ctx.lineTo(-12, -32); + ctx.lineTo(18, 12); + ctx.lineTo(36, -14); + ctx.lineTo(58, 36); + ctx.closePath(); + ctx.fill(); + } else if (emblem === "espresso") { + // Cup body + ctx.beginPath(); + ctx.roundRect(-34, -18, 62, 44, [4, 4, 24, 24]); + ctx.fill(); + // Handle + ctx.beginPath(); + ctx.arc(30, 2, 12, -Math.PI / 2, Math.PI / 2); + ctx.stroke(); + // Steam waves + ctx.beginPath(); + ctx.moveTo(-14, -28); + ctx.quadraticCurveTo(-20, -42, -10, -52); + ctx.moveTo(6, -28); + ctx.quadraticCurveTo(0, -42, 10, -52); + ctx.stroke(); + } else if (emblem === "lightbulb") { + ctx.beginPath(); + ctx.arc(0, -22, 28, 0, Math.PI * 2); + ctx.fillStyle = "#EFA51C"; + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = inkColor; + ctx.fillRect(-14, 6, 28, 16); + } else if (emblem === "compass") { + ctx.beginPath(); + ctx.moveTo(0, -54); + ctx.lineTo(14, -14); + ctx.lineTo(54, 0); + ctx.lineTo(14, 14); + ctx.lineTo(0, 54); + ctx.lineTo(-14, 14); + ctx.lineTo(-54, 0); + ctx.lineTo(-14, -14); + ctx.closePath(); + ctx.stroke(); + } else if (emblem === "lightning") { + ctx.beginPath(); + ctx.moveTo(8, -52); + ctx.lineTo(-24, -4); + ctx.lineTo(4, -4); + ctx.lineTo(-12, 44); + ctx.lineTo(28, -12); + ctx.lineTo(0, -12); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); +} + +// --- WHACK THIS IDEA DRAWER TAB --- +async function drawCardForActiveIdea() { + if (!state.activeIdea) return; + const suit = document.getElementById("drawer-whack-suit-select")?.value || ""; + const params = new URLSearchParams(); + if (suit) params.set("suit", suit); + else params.set("status", state.activeIdea.status); + + const data = await apiFetch(`/api/whack-cards/draw?${params.toString()}`); + state.drawerWhackCard = data.card; + renderDrawerActiveWhackStage(); +} + +function renderDrawerActiveWhackStage() { + const container = document.getElementById("drawer-active-whack-stage"); + const card = state.drawerWhackCard; + const idea = state.activeIdea; + if (!container || !card || !idea) return; + + container.innerHTML = ` +
    +
    + #${card.number} β€’ ${card.suitLabel} + ${escapeHtml(card.title)} +
    +
    +

    Card #${card.number}: ${escapeHtml(card.title)}

    +
    + ${escapeHtml(card.promptQuestion)} +
    +
    + + +
    + +
    + + +
    +
    +
    + `; + + document.getElementById("drawer-whack-card-img")?.addEventListener("click", () => openCardLightbox(card)); + + document.getElementById("btn-drawer-save-whack")?.addEventListener("click", async () => { + const reflection = document.getElementById("drawer-whack-reflection")?.value.trim(); + if (!reflection) { + showToast("Enter a reflection first!"); + return; + } + const addChecklistItem = document.getElementById("drawer-whack-add-chk")?.checked; + const res = await apiFetch(`/api/ideas/${idea.id}/whack`, { + method: "POST", + body: JSON.stringify({ + cardNumber: card.number, + reflection, + addChecklistItem + }) + }); + state.activeIdea = res.idea; + showToast(`Saved Card #${card.number} breakthrough!`); + await Promise.all([loadIdeas(), loadWhackCatalog()]); + }); + + document.getElementById("btn-drawer-whack-to-nb")?.addEventListener("click", () => { + const reflection = document.getElementById("drawer-whack-reflection")?.value.trim(); + switchDrawerTab("mockup"); + const twistSel = document.getElementById("nb-whack-twist-select"); + if (twistSel) twistSel.value = String(card.number); + const promptEl = document.getElementById("nb-prompt-input"); + if (promptEl) { + promptEl.value = reflection + ? `${idea.title} β€” ${reflection}` + : `${idea.title} (${idea.elevator_pitch || ""}) with twist from "${card.title}"`; + } + }); +} + +function renderDrawerWhackHistory(idea) { + const listEl = document.getElementById("drawer-whack-history-list"); + if (!listEl) return; + if (!idea.whack_history || idea.whack_history.length === 0) { + listEl.innerHTML = `

    No Whack Pack cards applied to this idea yet.

    `; + return; + } + listEl.innerHTML = idea.whack_history + .map( + (w) => ` +
    +
    + Card #${w.cardNumber}: ${escapeHtml(w.cardTitle)} + ${new Date(w.createdAt).toLocaleDateString()} +
    +

    ${escapeHtml(w.reflection)}

    +
    + ` + ) + .join(""); +} + +// --- QUICK WHACK BANNER & LIGHTBOX --- +function showQuickWhackBanner(card) { + state.quickBannerCard = card; + const banner = document.getElementById("quick-whack-banner"); + banner.classList.remove("hidden"); + document.getElementById("qwb-card-img").src = card.imageUrl; + const badge = document.getElementById("qwb-suit-badge"); + badge.textContent = `${card.suitLabel} #${card.number}`; + badge.className = `suit-pill suit-${card.suit}`; + document.getElementById("qwb-card-title").textContent = card.title; + document.getElementById("qwb-card-question").textContent = card.promptQuestion; +} + +function openCardLightbox(card) { + const modal = document.getElementById("lightbox-modal"); + modal.classList.remove("hidden"); + document.getElementById("lightbox-title").textContent = `Card #${card.number}: ${card.title} (${card.suitLabel})`; + document.getElementById("lightbox-img").src = card.imageUrl; + document.getElementById("lightbox-meta").innerHTML = ` +
    + ${escapeHtml(card.promptQuestion)} +
    + `; +} + +function openImageLightbox(url, title = "Mockup Preview") { + const modal = document.getElementById("lightbox-modal"); + modal.classList.remove("hidden"); + document.getElementById("lightbox-title").textContent = title; + document.getElementById("lightbox-img").src = url; + document.getElementById("lightbox-meta").innerHTML = ""; +} + +function switchWhackSubmode(mode) { + state.whackSubmode = mode; + document.querySelectorAll("[data-whack-mode]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.whackMode === mode); + }); + ["collider", "spread", "browser"].forEach((m) => { + document.getElementById(`whack-submode-${m}`)?.classList.toggle("hidden", m !== mode); + }); +} + +// --- GLOBAL EVENT BINDINGS --- +function bindGlobalEvents() { + // Keyboard shortcuts: Ctrl+K for Quick Capture, Esc to close modals/drawer + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + document.getElementById("quick-capture-input")?.focus(); + } else if (e.key === "Escape") { + document.getElementById("lightbox-modal")?.classList.add("hidden"); + document.getElementById("settings-modal")?.classList.add("hidden"); + closeIdeaDrawer(); + } + }); + + // Quick Capture Form + document.getElementById("quick-capture-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const input = document.getElementById("quick-capture-input"); + const category = document.getElementById("quick-capture-category").value; + const raw = input.value.trim(); + if (!raw) return; + + const payload = { title: raw, category }; + if (state.quickBannerCard) { + payload.inspired_by_card = state.quickBannerCard.number; + payload.elevator_pitch = `Sparked by Card #${state.quickBannerCard.number} (${state.quickBannerCard.title})`; + } + + const res = await apiFetch("/api/ideas", { + method: "POST", + body: JSON.stringify(payload) + }); + input.value = ""; + showToast(`✨ Captured "${res.idea.title}" in Spark!`); + await Promise.all([loadIdeas(), loadWhackCatalog()]); + }); + + // Quick Whack Prompt button + document.getElementById("btn-quick-whack-prompt")?.addEventListener("click", async () => { + const data = await apiFetch("/api/whack-cards/draw"); + if (data.card) showQuickWhackBanner(data.card); + }); + + document.getElementById("btn-qwb-redraw")?.addEventListener("click", async () => { + const data = await apiFetch("/api/whack-cards/draw"); + if (data.card) showQuickWhackBanner(data.card); + }); + + document.getElementById("btn-qwb-close")?.addEventListener("click", () => { + state.quickBannerCard = null; + document.getElementById("quick-whack-banner").classList.add("hidden"); + }); + + document.getElementById("btn-qwb-use")?.addEventListener("click", () => { + const input = document.getElementById("quick-capture-input"); + if (input && state.quickBannerCard) { + input.placeholder = `Idea inspired by #${state.quickBannerCard.number} (${state.quickBannerCard.title})...`; + input.focus(); + } + }); + + document.getElementById("qwb-card-img")?.addEventListener("click", () => { + if (state.quickBannerCard) openCardLightbox(state.quickBannerCard); + }); + + // Theme Toggle + document.getElementById("btn-theme-toggle")?.addEventListener("click", () => { + const html = document.documentElement; + const next = html.getAttribute("data-theme") === "dark" ? "paper" : "dark"; + html.setAttribute("data-theme", next); + document.getElementById("btn-theme-toggle").textContent = next === "dark" ? "β˜€οΈ Theme" : "πŸŒ™ Theme"; + }); + + // Export Dropdown + document.getElementById("btn-export-menu")?.addEventListener("click", () => { + document.getElementById("export-dropdown-menu")?.classList.toggle("hidden"); + }); + + // Main View Tabs + document.querySelectorAll(".view-tab").forEach((tab) => { + tab.addEventListener("click", async () => { + const view = tab.dataset.view; + state.currentView = view; + document.querySelectorAll(".view-tab").forEach((t) => t.classList.toggle("active", t === tab)); + ["board", "grid", "whack", "review"].forEach((v) => { + document.getElementById(`view-${v}`)?.classList.toggle("hidden", v !== view); + }); + if (view === "whack" && !state.colliderCard) { + await dealIdeaCollider(); + } + if (view === "review") { + await loadReviewSession(); + } + }); + }); + + // Global Filters + document.getElementById("filter-search")?.addEventListener("input", (e) => { + state.filters.search = e.target.value; + loadIdeas(); + }); + document.getElementById("filter-category")?.addEventListener("change", (e) => { + state.filters.category = e.target.value; + loadIdeas(); + }); + document.getElementById("filter-tag")?.addEventListener("change", (e) => { + state.filters.tag = e.target.value; + loadIdeas(); + }); + document.getElementById("filter-has-mockup")?.addEventListener("change", (e) => { + state.filters.hasMockup = e.target.checked; + loadIdeas(); + }); + document.getElementById("filter-sort")?.addEventListener("change", (e) => { + state.filters.sort = e.target.value; + loadIdeas(); + }); + + // Kanban Drag & Drop + document.querySelectorAll(".kanban-dropzone").forEach((zone) => { + zone.addEventListener("dragover", (e) => { + e.preventDefault(); + zone.classList.add("drag-over"); + }); + zone.addEventListener("dragleave", () => zone.classList.remove("drag-over")); + zone.addEventListener("drop", async (e) => { + e.preventDefault(); + zone.classList.remove("drag-over"); + const ideaId = Number(e.dataTransfer.getData("text/plain")); + const targetStatus = zone.dataset.status; + if (!ideaId || !targetStatus) return; + await apiFetch(`/api/ideas/${ideaId}`, { + method: "PATCH", + body: JSON.stringify({ status: targetStatus }) + }); + showToast(`Moved idea to ${targetStatus.toUpperCase()}`); + await loadIdeas(); + }); + }); + + // Explorer Grid Status Pills + document.querySelectorAll("#grid-status-pills .pill-btn").forEach((btn) => { + btn.addEventListener("click", () => { + state.gridStatusFilter = btn.dataset.status; + document.querySelectorAll("#grid-status-pills .pill-btn").forEach((b) => b.classList.toggle("active", b === btn)); + renderExplorerGrid(); + }); + }); + + document.getElementById("btn-new-idea-modal")?.addEventListener("click", async () => { + const res = await apiFetch("/api/ideas", { + method: "POST", + body: JSON.stringify({ title: "New Concept Spark", category: "creative" }) + }); + await loadIdeas(); + openIdeaDrawer(res.idea.id, "details"); + }); + + // Whack Studio Sub-mode Tabs + document.querySelectorAll("[data-whack-mode]").forEach((btn) => { + btn.addEventListener("click", async () => { + const mode = btn.dataset.whackMode; + switchWhackSubmode(mode); + if (mode === "spread" && state.spreadCards.length === 0) { + await dealFourRoleSpread(); + } + }); + }); + + document.getElementById("btn-deal-collider")?.addEventListener("click", () => dealIdeaCollider({ forceRandomIdea: false })); + document.getElementById("btn-collider-random-both")?.addEventListener("click", () => dealIdeaCollider({ forceRandomIdea: true })); + document.getElementById("btn-deal-spread")?.addEventListener("click", () => dealFourRoleSpread()); + + document.querySelectorAll("#deck-suit-filters .pill-btn").forEach((btn) => { + btn.addEventListener("click", () => { + state.deckSuitFilter = btn.dataset.suit; + document.querySelectorAll("#deck-suit-filters .pill-btn").forEach((b) => b.classList.toggle("active", b === btn)); + renderDeckBrowser(); + }); + }); + + document.getElementById("deck-search-input")?.addEventListener("input", (e) => { + state.deckSearch = e.target.value; + renderDeckBrowser(); + }); + + // Review Mode Tabs + document.querySelectorAll("[data-review-mode]").forEach((btn) => { + btn.addEventListener("click", () => { + const mode = btn.dataset.reviewMode; + state.reviewMode = mode; + document.querySelectorAll("[data-review-mode]").forEach((b) => b.classList.toggle("active", b === btn)); + document.getElementById("review-submode-arena")?.classList.toggle("hidden", mode !== "arena"); + document.getElementById("review-submode-triage")?.classList.toggle("hidden", mode !== "triage"); + }); + }); + + document.getElementById("btn-skip-arena")?.addEventListener("click", () => loadReviewSession()); + + // Drawer Events + document.getElementById("drawer-backdrop")?.addEventListener("click", closeIdeaDrawer); + document.getElementById("drawer-close-btn")?.addEventListener("click", closeIdeaDrawer); + + document.querySelectorAll(".drawer-tab").forEach((btn) => { + btn.addEventListener("click", async () => { + const tab = btn.dataset.drawerTab; + switchDrawerTab(tab); + if (tab === "whack" && !state.drawerWhackCard) { + await drawCardForActiveIdea(); + } + }); + }); + + document.getElementById("drawer-title-input")?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ title: e.target.value }); + }); + document.getElementById("drawer-status-select")?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ status: e.target.value }); + }); + document.getElementById("drawer-category-select")?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ category: e.target.value }); + }); + document.getElementById("drawer-pin-btn")?.addEventListener("click", () => { + if (!state.activeIdea) return; + saveActiveIdeaPatch({ pinned: !state.activeIdea.pinned }); + }); + document.getElementById("drawer-delete-btn")?.addEventListener("click", async () => { + if (!state.activeIdeaId) return; + await apiFetch(`/api/ideas/${state.activeIdeaId}`, { method: "DELETE" }); + showToast("Idea deleted"); + closeIdeaDrawer(); + await loadIdeas(); + }); + + document.getElementById("drawer-pitch-input")?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ elevator_pitch: e.target.value }); + }); + document.getElementById("drawer-notes-input")?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ notes_markdown: e.target.value }); + }); + + document.getElementById("btn-toggle-md-preview")?.addEventListener("click", () => { + const textarea = document.getElementById("drawer-notes-input"); + const preview = document.getElementById("drawer-notes-preview"); + preview.innerHTML = renderSimpleMarkdown(textarea.value); + textarea.classList.toggle("hidden"); + preview.classList.toggle("hidden"); + }); + + ["excitement", "impact", "ease"].forEach((metric) => { + const slider = document.getElementById(`slider-${metric}`); + slider?.addEventListener("input", (e) => { + document.getElementById(`val-${metric}`).textContent = e.target.value; + }); + slider?.addEventListener("change", (e) => { + saveActiveIdeaPatch({ [`rating_${metric}`]: Number(e.target.value) }); + }); + }); + + document.getElementById("drawer-add-tag-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.activeIdea) return; + const input = document.getElementById("drawer-new-tag-input"); + const val = input.value.replace(/^#/, "").trim().toLowerCase(); + if (!val) return; + input.value = ""; + const next = Array.from(new Set([...state.activeIdea.tags, val])); + await saveActiveIdeaPatch({ tags: next }); + }); + + document.getElementById("drawer-add-checklist-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.activeIdea) return; + const input = document.getElementById("drawer-new-checklist-input"); + const text = input.value.trim(); + if (!text) return; + input.value = ""; + const next = [...state.activeIdea.checklist, { id: `chk-${Date.now()}`, text, done: false }]; + await saveActiveIdeaPatch({ checklist: next }); + }); + + document.getElementById("drawer-add-pro-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.activeIdea) return; + const input = document.getElementById("drawer-new-pro-input"); + const text = input.value.trim(); + if (!text) return; + input.value = ""; + await saveActiveIdeaPatch({ pros: [...state.activeIdea.pros, text] }); + }); + + document.getElementById("drawer-add-con-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.activeIdea) return; + const input = document.getElementById("drawer-new-con-input"); + const text = input.value.trim(); + if (!text) return; + input.value = ""; + await saveActiveIdeaPatch({ cons: [...state.activeIdea.cons, text] }); + }); + + document.getElementById("drawer-add-link-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + if (!state.activeIdea) return; + const titleEl = document.getElementById("drawer-link-title"); + const urlEl = document.getElementById("drawer-link-url"); + if (!urlEl.value.trim()) return; + const next = [ + ...state.activeIdea.links, + { id: `lnk-${Date.now()}`, title: titleEl.value.trim() || urlEl.value.trim(), url: urlEl.value.trim() } + ]; + titleEl.value = ""; + urlEl.value = ""; + await saveActiveIdeaPatch({ links: next }); + }); + + // --- NANO BANANA MOCKUP STUDIO EVENTS --- + document.querySelectorAll("#nb-color-swatches .color-swatch").forEach((sw) => { + sw.addEventListener("click", () => { + document.querySelectorAll("#nb-color-swatches .color-swatch").forEach((s) => s.classList.remove("active")); + sw.classList.add("active"); + state.nbGarmentColor = sw.dataset.color; + state.nbGarmentColorName = sw.dataset.name; + document.getElementById("nb-selected-color-label").textContent = `${sw.dataset.name} (${sw.dataset.color})`; + renderCompositorCanvas(); + }); + }); + + document.getElementById("btn-studio-auto-prompt")?.addEventListener("click", () => { + if (!state.activeIdea) return; + const idea = state.activeIdea; + const twistNum = document.getElementById("nb-whack-twist-select")?.value; + const twistCard = twistNum ? state.whackCards.find((c) => c.number === Number(twistNum)) : null; + + let built = `${idea.title}`; + if (idea.elevator_pitch) built += ` β€” ${idea.elevator_pitch}`; + if (twistCard) { + built += `. Creative twist from Card #${twistCard.number} (${twistCard.title}): ${twistCard.promptQuestion}`; + } + document.getElementById("nb-prompt-input").value = built; + }); + + document.getElementById("btn-nb-clear-edit")?.addEventListener("click", () => { + state.nbEditParentMockup = null; + document.getElementById("nb-edit-mode-box").classList.add("hidden"); + document.getElementById("nb-edit-instruction-input").value = ""; + }); + + document.getElementById("btn-generate-nano-banana")?.addEventListener("click", async () => { + if (!state.activeIdeaId) return; + const btn = document.getElementById("btn-generate-nano-banana"); + const statusLabel = document.getElementById("nb-generation-status"); + const template = document.getElementById("nb-template-select").value; + const whackCardNumber = document.getElementById("nb-whack-twist-select").value; + const prompt = document.getElementById("nb-prompt-input").value.trim(); + const editInstruction = document.getElementById("nb-edit-instruction-input").value.trim(); + + btn.disabled = true; + statusLabel.textContent = "🍌 Calling Nano Banana 2.1 (gemini-nano-banana-2.1)..."; + + try { + const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/mockups/nano-banana`, { + method: "POST", + body: JSON.stringify({ + template, + garmentColor: state.nbGarmentColor, + garmentColorName: state.nbGarmentColorName, + whackCardNumber: whackCardNumber ? Number(whackCardNumber) : null, + prompt, + editInstruction: state.nbEditParentMockup ? editInstruction : "", + parentMockupId: state.nbEditParentMockup ? state.nbEditParentMockup.id : null + }) + }); + state.activeIdea = res.idea; + state.nbEditParentMockup = null; + document.getElementById("nb-edit-mode-box").classList.add("hidden"); + document.getElementById("nb-edit-instruction-input").value = ""; + statusLabel.textContent = "βœ“ Saved new Nano Banana mockup to gallery!"; + showToast("🍌 Nano Banana 2.1 generated & saved your mockup!"); + await loadIdeas(); + } catch (err) { + statusLabel.textContent = `⚠️ ${err.message}`; + if (err.code === "MISSING_API_KEY") { + document.getElementById("settings-modal")?.classList.remove("hidden"); + } else { + showToast(err.message); + } + } finally { + btn.disabled = false; + } + }); + + // Compositor controls + ["comp-surface-select", "comp-placement-select", "comp-emblem-select", "comp-top-text", "comp-bottom-text", "comp-ink-color"].forEach((id) => { + const el = document.getElementById(id); + el?.addEventListener("input", renderCompositorCanvas); + el?.addEventListener("change", renderCompositorCanvas); + }); + + document.getElementById("comp-upload-art")?.addEventListener("change", (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const img = new Image(); + img.onload = () => { + state.uploadedOverlayImage = img; + renderCompositorCanvas(); + }; + img.src = ev.target.result; + }; + reader.readAsDataURL(file); + }); + + document.getElementById("btn-clear-uploaded-art")?.addEventListener("click", () => { + state.uploadedOverlayImage = null; + const fileEl = document.getElementById("comp-upload-art"); + if (fileEl) fileEl.value = ""; + renderCompositorCanvas(); + }); + + document.getElementById("btn-save-canvas-mockup")?.addEventListener("click", async () => { + if (!state.activeIdeaId) return; + const canvas = document.getElementById("mockup-compositor-canvas"); + const imageDataUrl = canvas.toDataURL("image/png"); + const surface = document.getElementById("comp-surface-select").value; + const placement = document.getElementById("comp-placement-select").value; + const topText = document.getElementById("comp-top-text").value; + const bottomText = document.getElementById("comp-bottom-text").value; + + const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/mockups/canvas`, { + method: "POST", + body: JSON.stringify({ + title: `${state.nbGarmentColorName} ${surface === "tshirt" ? "Tee" : surface} (${placement})`, + template: surface === "tshirt" ? (placement === "left-chest" ? "tshirt-left-chest" : "tshirt-front") : surface, + garmentColor: state.nbGarmentColor, + garmentColorName: state.nbGarmentColorName, + placement, + topText, + bottomText, + imageDataUrl + }) + }); + state.activeIdea = res.idea; + showToast("πŸ“Έ Saved T-Shirt / Product Mockup to Idea Gallery!"); + await loadIdeas(); + }); + + document.getElementById("btn-download-canvas-png")?.addEventListener("click", () => { + const canvas = document.getElementById("mockup-compositor-canvas"); + const link = document.createElement("a"); + link.download = `sparkvault-mockup-${Date.now()}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + }); + + // Drawer Whack Card button + document.getElementById("btn-drawer-draw-whack")?.addEventListener("click", drawCardForActiveIdea); + + // Settings Modal + document.getElementById("btn-open-settings")?.addEventListener("click", () => { + document.getElementById("settings-modal")?.classList.remove("hidden"); + }); + document.getElementById("btn-close-settings")?.addEventListener("click", () => { + document.getElementById("settings-modal")?.classList.add("hidden"); + }); + document.getElementById("btn-cancel-settings")?.addEventListener("click", () => { + document.getElementById("settings-modal")?.classList.add("hidden"); + }); + + document.getElementById("settings-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const keyInput = document.getElementById("settings-api-key"); + const modelSel = document.getElementById("settings-model-select"); + const payload = { nanoBananaModel: modelSel.value }; + if (keyInput.value.trim()) { + payload.geminiApiKey = keyInput.value.trim(); + } + await apiFetch("/api/settings", { + method: "PUT", + body: JSON.stringify(payload) + }); + keyInput.value = ""; + document.getElementById("settings-modal")?.classList.add("hidden"); + showToast("🍌 Saved Nano Banana 2.1 settings!"); + await loadSettings(); + }); + + // Lightbox Close + document.getElementById("btn-close-lightbox")?.addEventListener("click", () => { + document.getElementById("lightbox-modal")?.classList.add("hidden"); + }); +} + +initApp(); diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..52ac98b --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3374420 --- /dev/null +++ b/public/index.html @@ -0,0 +1,694 @@ + + + + + + SparkVault β€” Idea Tracker, Creative Whack Pack & Nano Banana Mockup Studio + + + + + +
    +
    + +
    +
    +

    SparkVault

    + Whack Pack + 🍌 Nano Banana 2.1 +
    +

    Capture sparks, break blocks with 64 Creative Whack cards, and mock up visuals

    +
    +
    + + +
    + ✨ + + + + +
    + +
    + + + +
    +
    + + + + + + + + +
    + +
    +
    +
    +
    +
    + +

    ✨ Spark

    + 🧭 Explorer 1–16 +
    + 0 +
    +
    +
    + +
    +
    +
    + +

    πŸ” Exploring

    + 🎨 Artist & βš–οΈ Judge +
    + 0 +
    +
    +
    + +
    +
    +
    + +

    πŸ› οΈ Building

    + βš”οΈ Warrior 49–64 +
    + 0 +
    +
    +
    + +
    +
    +
    + +

    πŸš€ Shipped

    + Launched +
    + 0 +
    +
    +
    + +
    +
    +
    + +

    πŸ“¦ Archived

    + Parked Vault +
    + 0 +
    +
    +
    +
    +
    + + + + + + + + + +
    + + + + + + + + + + + +
    + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..82c78ec --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1373 @@ +:root, +[data-theme="dark"] { + --bg-base: #0f1117; + --bg-surface: #171b24; + --bg-elevated: #1f2532; + --bg-subtle: #252c3b; + --border-color: #2e374a; + --border-strong: #414e68; + --text-primary: #f3f5f9; + --text-secondary: #a8b3c7; + --text-muted: #75829b; + --accent-primary: #f59e0b; + --accent-primary-hover: #fbbf24; + --accent-secondary: #6366f1; + --suit-explorer: #2b7ec1; + --suit-artist: #d95f3b; + --suit-judge: #2a9158; + --suit-warrior: #b8335a; + --status-spark: #38bdf8; + --status-exploring: #fb923c; + --status-building: #a855f7; + --status-shipped: #22c55e; + --status-archived: #64748b; + --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.45); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; +} + +[data-theme="paper"] { + --bg-base: #f5efe4; + --bg-surface: #fbf8f1; + --bg-elevated: #ffffff; + --bg-subtle: #ede4d3; + --border-color: #d8ccb8; + --border-strong: #b8a88f; + --text-primary: #1f1b16; + --text-secondary: #53493c; + --text-muted: #7d705f; + --accent-primary: #c85a17; + --accent-primary-hover: #df6920; + --accent-secondary: #3949ab; + --shadow-lg: 0 16px 36px rgba(45, 35, 20, 0.14); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; + background: var(--bg-base); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.hidden { + display: none !important; +} + +/* TOPBAR & QUICK CAPTURE */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 30; + flex-wrap: wrap; +} + +.topbar-brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-logo { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--accent-primary), #ec4899); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25); +} + +.brand-title-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.brand-title { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 0.12rem 0.48rem; + border-radius: 999px; + background: var(--bg-subtle); + color: var(--accent-primary); + border: 1px solid var(--border-color); +} + +.brand-subtitle { + font-size: 0.75rem; + color: var(--text-muted); +} + +.quick-capture-bar { + flex: 1; + max-width: 720px; + min-width: 300px; + display: flex; + align-items: center; + gap: 0.45rem; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + padding: 0.35rem 0.5rem; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.quick-capture-bar:focus-within { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.22); +} + +.qc-icon { + font-size: 1rem; + padding-left: 0.25rem; +} + +#quick-capture-input { + flex: 1; + background: transparent; + border: none; + color: var(--text-primary); + font-size: 0.9rem; + outline: none; +} + +#quick-capture-category { + background: var(--bg-subtle); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.28rem 0.5rem; + font-size: 0.8rem; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +/* WHACK PROMPT BANNER */ +.whack-prompt-banner { + background: linear-gradient(90deg, var(--bg-elevated), var(--bg-surface)); + border-bottom: 2px solid var(--accent-primary); + padding: 0.75rem 1.5rem; +} + +.whack-banner-inner { + max-width: 1180px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 1rem; +} + +.qwb-thumb { + width: 66px; + height: 98px; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: #fff; + cursor: pointer; +} + +.qwb-content { + flex: 1; +} + +.qwb-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.2rem; +} + +.qwb-question { + font-size: 0.95rem; + color: var(--accent-primary); + font-weight: 600; +} + +.qwb-actions { + display: flex; + gap: 0.45rem; +} + +/* SUBNAV & FILTERS */ +.subnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 1.25rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.view-tabs { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.view-tab { + background: transparent; + color: var(--text-secondary); + border: 1px solid transparent; + padding: 0.45rem 0.85rem; + border-radius: var(--radius-sm); + font-size: 0.86rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + transition: all 0.15s ease; +} + +.view-tab:hover { + background: var(--bg-subtle); + color: var(--text-primary); +} + +.view-tab.active { + background: var(--bg-elevated); + color: var(--accent-primary); + border-color: var(--border-strong); +} + +.tab-pill { + font-size: 0.7rem; + padding: 0.08rem 0.4rem; + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-primary); +} + +.filter-bar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.filter-input, +.filter-select, +.text-input, +.textarea-input, +.drawer-select { + background: var(--bg-elevated); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.4rem 0.65rem; + font-size: 0.83rem; +} + +.filter-checkbox { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--text-secondary); + padding: 0.32rem 0.6rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; +} + +/* BUTTONS */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.48rem 0.9rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + text-decoration: none; +} + +.btn-sm { + padding: 0.32rem 0.65rem; + font-size: 0.8rem; +} + +.btn-xs { + padding: 0.18rem 0.45rem; + font-size: 0.74rem; +} + +.btn-primary { + background: var(--accent-primary); + color: #111318; +} + +.btn-primary:hover { + background: var(--accent-primary-hover); +} + +.btn-secondary { + background: var(--accent-secondary); + color: #ffffff; +} + +.btn-ghost { + background: var(--bg-elevated); + color: var(--text-primary); + border-color: var(--border-color); +} + +.btn-ghost:hover { + background: var(--bg-subtle); + border-color: var(--border-strong); +} + +.btn-danger-ghost { + background: transparent; + color: #ef4444; + border-color: rgba(239, 68, 68, 0.35); +} + +.pill-btn { + background: var(--bg-elevated); + color: var(--text-secondary); + border: 1px solid var(--border-color); + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.pill-btn.active { + background: var(--accent-primary); + color: #121316; + border-color: var(--accent-primary); +} + +/* WORKSPACE & KANBAN */ +.workspace { + flex: 1; + padding: 1rem 1.25rem; + overflow-x: auto; +} + +.kanban-board { + display: grid; + grid-template-columns: repeat(5, minmax(275px, 1fr)); + gap: 0.9rem; + align-items: start; + min-height: calc(100vh - 175px); +} + +.kanban-column { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + min-height: 540px; +} + +.kanban-col-header { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.kanban-col-header h2 { + font-size: 0.92rem; + font-weight: 700; + display: inline; +} + +.role-hint { + display: block; + font-size: 0.71rem; + color: var(--text-muted); + margin-top: 0.1rem; +} + +.col-count { + font-size: 0.75rem; + font-weight: 700; + padding: 0.12rem 0.48rem; + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-secondary); +} + +.kanban-dropzone { + padding: 0.65rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.65rem; + min-height: 460px; +} + +.kanban-dropzone.drag-over { + background: rgba(245, 158, 11, 0.08); + outline: 2px dashed var(--accent-primary); + outline-offset: -4px; +} + +/* IDEA CARD */ +.idea-card { + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.75rem; + cursor: pointer; + transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease; + display: flex; + flex-direction: column; + gap: 0.5rem; + position: relative; +} + +.idea-card:hover { + transform: translateY(-2px); + border-color: var(--accent-primary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22); +} + +.idea-card.pinned { + border-left: 3px solid var(--accent-primary); +} + +.idea-card-mockup-thumb { + width: 100%; + height: 155px; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: #14171f; +} + +.idea-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; +} + +.cat-badge { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.12rem 0.42rem; + border-radius: 4px; + background: var(--bg-subtle); + color: var(--text-secondary); +} + +.score-pill { + font-size: 0.72rem; + font-weight: 700; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: rgba(245, 158, 11, 0.15); + color: var(--accent-primary); + border: 1px solid rgba(245, 158, 11, 0.35); +} + +.idea-card-title { + font-size: 0.94rem; + font-weight: 700; + line-height: 1.35; +} + +.idea-card-pitch { + font-size: 0.8rem; + color: var(--text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.idea-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.tag-chip { + font-size: 0.7rem; + padding: 0.08rem 0.38rem; + border-radius: 4px; + background: var(--bg-subtle); + color: var(--text-secondary); +} + +.idea-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.35rem; + border-top: 1px solid var(--border-color); + font-size: 0.73rem; + color: var(--text-muted); +} + +.card-quick-actions { + display: flex; + gap: 0.25rem; +} + +.icon-btn { + background: var(--bg-subtle); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 0.12rem 0.38rem; + font-size: 0.72rem; + cursor: pointer; +} + +.icon-btn:hover { + color: var(--text-primary); + border-color: var(--accent-primary); +} + +/* EXPLORER GRID */ +.grid-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 0.75rem; +} + +.status-pills { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.explorer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 1rem; +} + +/* CREATIVE WHACK PACK STUDIO */ +.whack-studio-header, +.review-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.whack-mode-tabs, +.review-mode-tabs { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.collider-controls { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.collider-control-group { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.84rem; +} + +.collider-stage { + display: grid; + grid-template-columns: minmax(300px, 380px) 1fr; + gap: 1.25rem; + align-items: start; +} + +@media (max-width: 900px) { + .collider-stage { + grid-template-columns: 1fr; + } +} + +.whack-card-showcase { + background: var(--bg-surface); + border: 2px solid var(--border-strong); + border-radius: var(--radius-lg); + padding: 1rem; + text-align: center; + box-shadow: var(--shadow-lg); +} + +.whack-card-scan { + width: 100%; + max-width: 320px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + background: #fff; + cursor: zoom-in; +} + +.suit-pill { + display: inline-block; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.15rem 0.55rem; + border-radius: 999px; + color: #fff; +} + +.suit-explorer { background: var(--suit-explorer); } +.suit-artist { background: var(--suit-artist); } +.suit-judge { background: var(--suit-judge); } +.suit-warrior { background: var(--suit-warrior); } + +.collider-workbench { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.prompt-callout { + padding: 0.9rem 1rem; + background: var(--bg-elevated); + border-left: 4px solid var(--accent-primary); + border-radius: var(--radius-sm); +} + +.spread-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.85rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.spread-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); + gap: 1rem; +} + +.deck-filter-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.suit-filter-buttons { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.deck-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); + gap: 1rem; +} + +.deck-card-item { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.65rem; + display: flex; + flex-direction: column; + gap: 0.45rem; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.deck-card-item:hover { + transform: translateY(-3px); + border-color: var(--accent-primary); +} + +.deck-card-item img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: var(--radius-sm); + background: #fff; + cursor: pointer; +} + +/* ARENA & TRIAGE */ +.arena-instructions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.arena-stage { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1.25rem; + align-items: stretch; +} + +@media (max-width: 860px) { + .arena-stage { + grid-template-columns: 1fr; + } +} + +.arena-fighter { + background: var(--bg-surface); + border: 2px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.35rem; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + transition: border-color 0.15s ease, transform 0.15s ease; +} + +.arena-fighter:hover { + border-color: var(--accent-primary); + transform: translateY(-2px); +} + +.arena-vs-badge { + align-self: center; + font-size: 1.2rem; + font-weight: 800; + width: 48px; + height: 48px; + border-radius: 999px; + background: var(--bg-elevated); + border: 2px solid var(--border-strong); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-primary); +} + +.triage-card { + max-width: 720px; + margin: 0 auto; + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* SLIDE-OVER DRAWER & MOCKUP STUDIO */ +.drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(5, 7, 12, 0.62); + backdrop-filter: blur(2px); + z-index: 50; +} + +.idea-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(1040px, 95vw); + background: var(--bg-surface); + border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + z-index: 60; + display: flex; + flex-direction: column; +} + +.drawer-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.drawer-title-group { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.drawer-title-input { + width: 100%; + font-size: 1.25rem; + font-weight: 700; + background: transparent; + color: var(--text-primary); + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 0.2rem 0.4rem; +} + +.drawer-title-input:focus { + background: var(--bg-elevated); + border-color: var(--accent-primary); + outline: none; +} + +.drawer-meta-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.score-badge { + font-size: 0.8rem; + font-weight: 700; + padding: 0.25rem 0.65rem; + border-radius: 999px; + background: rgba(245, 158, 11, 0.16); + color: var(--accent-primary); +} + +.drawer-tabs { + display: flex; + gap: 0.35rem; + padding: 0.55rem 1.25rem; + background: var(--bg-base); + border-bottom: 1px solid var(--border-color); +} + +.drawer-tab { + background: transparent; + color: var(--text-secondary); + border: 1px solid transparent; + padding: 0.45rem 0.9rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.drawer-tab.active { + background: var(--bg-surface); + color: var(--accent-primary); + border-color: var(--border-color); +} + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 1.25rem; +} + +.drawer-tab-panel { + display: flex; + flex-direction: column; + gap: 1.15rem; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field-group label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); +} + +.field-header-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.full-width { + width: 100%; +} + +.ratings-card { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + padding: 0.85rem 1rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.rating-control { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.82rem; +} + +.tags-editor { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.tags-list { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.tag-removable { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + padding: 0.18rem 0.5rem; + border-radius: 999px; + font-size: 0.76rem; +} + +.tag-removable button { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; +} + +.inline-add-form { + display: flex; + gap: 0.4rem; +} + +.checklist-container { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.checklist-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.4rem 0.65rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.checklist-row.done span { + text-decoration: line-through; + color: var(--text-muted); +} + +.pros-cons-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.bullet-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.bullet-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.38rem 0.6rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 0.82rem; +} + +.markdown-preview { + padding: 0.85rem 1rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 0.88rem; +} + +/* NANO BANANA MOCKUP STUDIO */ +.mockup-studio-layout { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 1.15rem; + align-items: start; +} + +@media (max-width: 900px) { + .mockup-studio-layout { + grid-template-columns: 1fr; + } +} + +.mockup-controls-pane { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.studio-card { + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.studio-card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.studio-card-header h3 { + font-size: 0.95rem; + font-weight: 700; +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; +} + +.form-row-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.65rem; +} + +.color-swatch-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.color-swatch { + width: 28px; + height: 28px; + border-radius: 999px; + border: 2px solid var(--border-strong); + cursor: pointer; + transition: transform 0.12s ease; +} + +.color-swatch.active { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; + transform: scale(1.1); +} + +.nb-edit-box { + background: rgba(245, 158, 11, 0.1); + border: 1px solid var(--accent-primary); + border-radius: var(--radius-sm); + padding: 0.65rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.nb-edit-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; +} + +.canvas-preview-wrapper { + width: 100%; + display: flex; + justify-content: center; + background: #12151e; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.5rem; +} + +#mockup-compositor-canvas { + width: 100%; + max-width: 400px; + height: auto; + border-radius: var(--radius-sm); +} + +.studio-action-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.drawer-mockup-gallery { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.mockup-gallery-card { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.65rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.mockup-gallery-card.is-cover { + border-color: var(--accent-primary); +} + +.mockup-gallery-card img { + width: 100%; + border-radius: var(--radius-sm); + cursor: zoom-in; + max-height: 290px; + object-fit: contain; + background: #101218; +} + +.mockup-gallery-actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +/* WHACK THIS IDEA TAB */ +.drawer-whack-controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.85rem 1rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + flex-wrap: wrap; +} + +.drawer-whack-actions { + display: flex; + gap: 0.5rem; +} + +.whack-history-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.whack-log-item { + padding: 0.75rem 0.9rem; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); +} + +/* MODALS & DROPDOWNS & TOASTS */ +.dropdown { + position: relative; +} + +.dropdown-menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + min-width: 190px; + z-index: 80; + display: flex; + flex-direction: column; +} + +.dropdown-item { + padding: 0.55rem 0.85rem; + color: var(--text-primary); + text-decoration: none; + font-size: 0.82rem; +} + +.dropdown-item:hover { + background: var(--bg-subtle); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(5, 7, 12, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 90; + padding: 1rem; +} + +.modal-card, +.lightbox-card { + background: var(--bg-surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + width: min(540px, 95vw); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.lightbox-card { + width: min(680px, 95vw); +} + +.modal-header { + padding: 0.9rem 1.15rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-body, +.lightbox-body { + padding: 1.15rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.lightbox-body img { + max-height: 72vh; + width: auto; + max-width: 100%; + margin: 0 auto; + border-radius: var(--radius-md); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 0.5rem; +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--text-muted); +} + +.status-dot.online { background: #22c55e; } +.status-spark { background: var(--status-spark); } +.status-exploring { background: var(--status-exploring); } +.status-building { background: var(--status-building); } +.status-shipped { background: var(--status-shipped); } +.status-archived { background: var(--status-archived); } + +.muted-label { + font-size: 0.76rem; + color: var(--text-muted); +} + +.toast-container { + position: fixed; + bottom: 1.25rem; + right: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 100; +} + +.toast { + background: var(--bg-elevated); + color: var(--text-primary); + border-left: 4px solid var(--accent-primary); + border-radius: var(--radius-sm); + padding: 0.65rem 1rem; + box-shadow: var(--shadow-lg); + font-size: 0.84rem; +} diff --git a/public/whack_pack/1.give_yourself_a_whack_on_the_side_of_the_head.jpg b/public/whack_pack/1.give_yourself_a_whack_on_the_side_of_the_head.jpg new file mode 100644 index 0000000..9fd0c93 Binary files /dev/null and b/public/whack_pack/1.give_yourself_a_whack_on_the_side_of_the_head.jpg differ diff --git a/public/whack_pack/10.find_a_pattern.jpg b/public/whack_pack/10.find_a_pattern.jpg new file mode 100644 index 0000000..b0e8baa Binary files /dev/null and b/public/whack_pack/10.find_a_pattern.jpg differ diff --git a/public/whack_pack/11.see_hear_tast_feel_smell.jpg b/public/whack_pack/11.see_hear_tast_feel_smell.jpg new file mode 100644 index 0000000..b2beb37 Binary files /dev/null and b/public/whack_pack/11.see_hear_tast_feel_smell.jpg differ diff --git a/public/whack_pack/12.listen_to_your_dreams.jpg b/public/whack_pack/12.listen_to_your_dreams.jpg new file mode 100644 index 0000000..a496e16 Binary files /dev/null and b/public/whack_pack/12.listen_to_your_dreams.jpg differ diff --git a/public/whack_pack/13.look_to_nature.jpg b/public/whack_pack/13.look_to_nature.jpg new file mode 100644 index 0000000..ff5a469 Binary files /dev/null and b/public/whack_pack/13.look_to_nature.jpg differ diff --git a/public/whack_pack/14.look_to_the_past.jpg b/public/whack_pack/14.look_to_the_past.jpg new file mode 100644 index 0000000..e0cf333 Binary files /dev/null and b/public/whack_pack/14.look_to_the_past.jpg differ diff --git a/public/whack_pack/15.let_your_mind_wander.jpg b/public/whack_pack/15.let_your_mind_wander.jpg new file mode 100644 index 0000000..57779c6 Binary files /dev/null and b/public/whack_pack/15.let_your_mind_wander.jpg differ diff --git a/public/whack_pack/16.see_the_obvious.jpg b/public/whack_pack/16.see_the_obvious.jpg new file mode 100644 index 0000000..e44cce4 Binary files /dev/null and b/public/whack_pack/16.see_the_obvious.jpg differ diff --git a/public/whack_pack/17.think_like_a_kid.jpg b/public/whack_pack/17.think_like_a_kid.jpg new file mode 100644 index 0000000..18d75af Binary files /dev/null and b/public/whack_pack/17.think_like_a_kid.jpg differ diff --git a/public/whack_pack/18.ask_what_if.jpg b/public/whack_pack/18.ask_what_if.jpg new file mode 100644 index 0000000..4140c60 Binary files /dev/null and b/public/whack_pack/18.ask_what_if.jpg differ diff --git a/public/whack_pack/19.challenge_the_rules.jpg b/public/whack_pack/19.challenge_the_rules.jpg new file mode 100644 index 0000000..453f927 Binary files /dev/null and b/public/whack_pack/19.challenge_the_rules.jpg differ diff --git a/public/whack_pack/2.ask_why.jpg b/public/whack_pack/2.ask_why.jpg new file mode 100644 index 0000000..915c4f6 Binary files /dev/null and b/public/whack_pack/2.ask_why.jpg differ diff --git a/public/whack_pack/20.be_whacky.jpg b/public/whack_pack/20.be_whacky.jpg new file mode 100644 index 0000000..7955aba Binary files /dev/null and b/public/whack_pack/20.be_whacky.jpg differ diff --git a/public/whack_pack/21.simplify.jpg b/public/whack_pack/21.simplify.jpg new file mode 100644 index 0000000..79b006b Binary files /dev/null and b/public/whack_pack/21.simplify.jpg differ diff --git a/public/whack_pack/22.exaggerate.jpg b/public/whack_pack/22.exaggerate.jpg new file mode 100644 index 0000000..68fc36c Binary files /dev/null and b/public/whack_pack/22.exaggerate.jpg differ diff --git a/public/whack_pack/23.rearrange.jpg b/public/whack_pack/23.rearrange.jpg new file mode 100644 index 0000000..c6d8681 Binary files /dev/null and b/public/whack_pack/23.rearrange.jpg differ diff --git a/public/whack_pack/24.substitute.jpg b/public/whack_pack/24.substitute.jpg new file mode 100644 index 0000000..8e968a3 Binary files /dev/null and b/public/whack_pack/24.substitute.jpg differ diff --git a/public/whack_pack/25.combine_ideas.jpg b/public/whack_pack/25.combine_ideas.jpg new file mode 100644 index 0000000..0eec347 Binary files /dev/null and b/public/whack_pack/25.combine_ideas.jpg differ diff --git a/public/whack_pack/26.make_a_metaphor.jpg b/public/whack_pack/26.make_a_metaphor.jpg new file mode 100644 index 0000000..aa679ad Binary files /dev/null and b/public/whack_pack/26.make_a_metaphor.jpg differ diff --git a/public/whack_pack/27.reverse.jpg b/public/whack_pack/27.reverse.jpg new file mode 100644 index 0000000..0413c98 Binary files /dev/null and b/public/whack_pack/27.reverse.jpg differ diff --git a/public/whack_pack/28.imagine_how_others_woud_do_it.jpg b/public/whack_pack/28.imagine_how_others_woud_do_it.jpg new file mode 100644 index 0000000..7f7d7cf Binary files /dev/null and b/public/whack_pack/28.imagine_how_others_woud_do_it.jpg differ diff --git a/public/whack_pack/29.imagine_youre_the_idea.jpg b/public/whack_pack/29.imagine_youre_the_idea.jpg new file mode 100644 index 0000000..42fbd2d Binary files /dev/null and b/public/whack_pack/29.imagine_youre_the_idea.jpg differ diff --git a/public/whack_pack/3.get_out_of_your_box.jpg b/public/whack_pack/3.get_out_of_your_box.jpg new file mode 100644 index 0000000..8177c7d Binary files /dev/null and b/public/whack_pack/3.get_out_of_your_box.jpg differ diff --git a/public/whack_pack/30.change_its_name.jpg b/public/whack_pack/30.change_its_name.jpg new file mode 100644 index 0000000..e8b6529 Binary files /dev/null and b/public/whack_pack/30.change_its_name.jpg differ diff --git a/public/whack_pack/31.dont_force_it.jpg b/public/whack_pack/31.dont_force_it.jpg new file mode 100644 index 0000000..cca07fd Binary files /dev/null and b/public/whack_pack/31.dont_force_it.jpg differ diff --git a/public/whack_pack/32.pause_for_a_bit.jpg b/public/whack_pack/32.pause_for_a_bit.jpg new file mode 100644 index 0000000..b9eb251 Binary files /dev/null and b/public/whack_pack/32.pause_for_a_bit.jpg differ diff --git a/public/whack_pack/33.see_the_positive.jpg b/public/whack_pack/33.see_the_positive.jpg new file mode 100644 index 0000000..9ab593f Binary files /dev/null and b/public/whack_pack/33.see_the_positive.jpg differ diff --git a/public/whack_pack/34.find_out_whats_out_of_whack.jpg b/public/whack_pack/34.find_out_whats_out_of_whack.jpg new file mode 100644 index 0000000..e104524 Binary files /dev/null and b/public/whack_pack/34.find_out_whats_out_of_whack.jpg differ diff --git a/public/whack_pack/35.solve_the_right_problem.jpg b/public/whack_pack/35.solve_the_right_problem.jpg new file mode 100644 index 0000000..dfc7e1b Binary files /dev/null and b/public/whack_pack/35.solve_the_right_problem.jpg differ diff --git a/public/whack_pack/36.conform.jpg b/public/whack_pack/36.conform.jpg new file mode 100644 index 0000000..3117f46 Binary files /dev/null and b/public/whack_pack/36.conform.jpg differ diff --git a/public/whack_pack/37.ask_a_fool.jpg b/public/whack_pack/37.ask_a_fool.jpg new file mode 100644 index 0000000..f81fe30 Binary files /dev/null and b/public/whack_pack/37.ask_a_fool.jpg differ diff --git a/public/whack_pack/38.focus_on_the_real_truth.jpg b/public/whack_pack/38.focus_on_the_real_truth.jpg new file mode 100644 index 0000000..95050ba Binary files /dev/null and b/public/whack_pack/38.focus_on_the_real_truth.jpg differ diff --git a/public/whack_pack/39.slay_a_sacred_cow.jpg b/public/whack_pack/39.slay_a_sacred_cow.jpg new file mode 100644 index 0000000..6778fc5 Binary files /dev/null and b/public/whack_pack/39.slay_a_sacred_cow.jpg differ diff --git a/public/whack_pack/4.look_somewhere_else.jpg b/public/whack_pack/4.look_somewhere_else.jpg new file mode 100644 index 0000000..b6e8979 Binary files /dev/null and b/public/whack_pack/4.look_somewhere_else.jpg differ diff --git a/public/whack_pack/40.loosen_up.jpg b/public/whack_pack/40.loosen_up.jpg new file mode 100644 index 0000000..121a8b4 Binary files /dev/null and b/public/whack_pack/40.loosen_up.jpg differ diff --git a/public/whack_pack/41.check_your_timing.jpg b/public/whack_pack/41.check_your_timing.jpg new file mode 100644 index 0000000..bf3137f Binary files /dev/null and b/public/whack_pack/41.check_your_timing.jpg differ diff --git a/public/whack_pack/42.beware_the_unintended.jpg b/public/whack_pack/42.beware_the_unintended.jpg new file mode 100644 index 0000000..fc35b3b Binary files /dev/null and b/public/whack_pack/42.beware_the_unintended.jpg differ diff --git a/public/whack_pack/43.get_out_of_the_dogma_house.jpg b/public/whack_pack/43.get_out_of_the_dogma_house.jpg new file mode 100644 index 0000000..d6e9f59 Binary files /dev/null and b/public/whack_pack/43.get_out_of_the_dogma_house.jpg differ diff --git a/public/whack_pack/44.be_dissatisfied.jpg b/public/whack_pack/44.be_dissatisfied.jpg new file mode 100644 index 0000000..c1b8865 Binary files /dev/null and b/public/whack_pack/44.be_dissatisfied.jpg differ diff --git a/public/whack_pack/45.dont_fall_in_love_with_ideas.jpg b/public/whack_pack/45.dont_fall_in_love_with_ideas.jpg new file mode 100644 index 0000000..b714c5e Binary files /dev/null and b/public/whack_pack/45.dont_fall_in_love_with_ideas.jpg differ diff --git a/public/whack_pack/46.avoid_arrogance.jpg b/public/whack_pack/46.avoid_arrogance.jpg new file mode 100644 index 0000000..dccb118 Binary files /dev/null and b/public/whack_pack/46.avoid_arrogance.jpg differ diff --git a/public/whack_pack/47.listen_to_that_hunch.jpg b/public/whack_pack/47.listen_to_that_hunch.jpg new file mode 100644 index 0000000..e13f212 Binary files /dev/null and b/public/whack_pack/47.listen_to_that_hunch.jpg differ diff --git a/public/whack_pack/48.hear_the_knock_of_opportunity.jpg b/public/whack_pack/48.hear_the_knock_of_opportunity.jpg new file mode 100644 index 0000000..b8dc8ab Binary files /dev/null and b/public/whack_pack/48.hear_the_knock_of_opportunity.jpg differ diff --git a/public/whack_pack/49.take_a_whack_at_it.jpg b/public/whack_pack/49.take_a_whack_at_it.jpg new file mode 100644 index 0000000..a9121ca Binary files /dev/null and b/public/whack_pack/49.take_a_whack_at_it.jpg differ diff --git a/public/whack_pack/5.dig_deeper.jpg b/public/whack_pack/5.dig_deeper.jpg new file mode 100644 index 0000000..42cc2ce Binary files /dev/null and b/public/whack_pack/5.dig_deeper.jpg differ diff --git a/public/whack_pack/50.believe_in_yourself.jpg b/public/whack_pack/50.believe_in_yourself.jpg new file mode 100644 index 0000000..3ef6edc Binary files /dev/null and b/public/whack_pack/50.believe_in_yourself.jpg differ diff --git a/public/whack_pack/51.put_a_lion_in_your_heart.jpg b/public/whack_pack/51.put_a_lion_in_your_heart.jpg new file mode 100644 index 0000000..99600af Binary files /dev/null and b/public/whack_pack/51.put_a_lion_in_your_heart.jpg differ diff --git a/public/whack_pack/52.fight_for_it.jpg b/public/whack_pack/52.fight_for_it.jpg new file mode 100644 index 0000000..dd5ee2a Binary files /dev/null and b/public/whack_pack/52.fight_for_it.jpg differ diff --git a/public/whack_pack/53.flex_your_risk_muscle.jpg b/public/whack_pack/53.flex_your_risk_muscle.jpg new file mode 100644 index 0000000..17b92f5 Binary files /dev/null and b/public/whack_pack/53.flex_your_risk_muscle.jpg differ diff --git a/public/whack_pack/54.get_support.jpg b/public/whack_pack/54.get_support.jpg new file mode 100644 index 0000000..348d594 Binary files /dev/null and b/public/whack_pack/54.get_support.jpg differ diff --git a/public/whack_pack/55.get_rid_of_excuses.jpg b/public/whack_pack/55.get_rid_of_excuses.jpg new file mode 100644 index 0000000..6e55f1f Binary files /dev/null and b/public/whack_pack/55.get_rid_of_excuses.jpg differ diff --git a/public/whack_pack/56.slay_a_dragon.jpg b/public/whack_pack/56.slay_a_dragon.jpg new file mode 100644 index 0000000..ef77475 Binary files /dev/null and b/public/whack_pack/56.slay_a_dragon.jpg differ diff --git a/public/whack_pack/57.have_something_at_stake.jpg b/public/whack_pack/57.have_something_at_stake.jpg new file mode 100644 index 0000000..b457d03 Binary files /dev/null and b/public/whack_pack/57.have_something_at_stake.jpg differ diff --git a/public/whack_pack/58.use_your_shield.jpg b/public/whack_pack/58.use_your_shield.jpg new file mode 100644 index 0000000..9e7ae62 Binary files /dev/null and b/public/whack_pack/58.use_your_shield.jpg differ diff --git a/public/whack_pack/59.do_the_unexpected.jpg b/public/whack_pack/59.do_the_unexpected.jpg new file mode 100644 index 0000000..c4d0d2d Binary files /dev/null and b/public/whack_pack/59.do_the_unexpected.jpg differ diff --git a/public/whack_pack/6.change_viewpoints.jpg b/public/whack_pack/6.change_viewpoints.jpg new file mode 100644 index 0000000..52ed3ea Binary files /dev/null and b/public/whack_pack/6.change_viewpoints.jpg differ diff --git a/public/whack_pack/60.sell_sell_sell.jpg b/public/whack_pack/60.sell_sell_sell.jpg new file mode 100644 index 0000000..751be84 Binary files /dev/null and b/public/whack_pack/60.sell_sell_sell.jpg differ diff --git a/public/whack_pack/61.set_a_deadline.jpg b/public/whack_pack/61.set_a_deadline.jpg new file mode 100644 index 0000000..5255cdf Binary files /dev/null and b/public/whack_pack/61.set_a_deadline.jpg differ diff --git a/public/whack_pack/62.be_persistent.jpg b/public/whack_pack/62.be_persistent.jpg new file mode 100644 index 0000000..4d74d65 Binary files /dev/null and b/public/whack_pack/62.be_persistent.jpg differ diff --git a/public/whack_pack/63.learn_from_mistakes.jpg b/public/whack_pack/63.learn_from_mistakes.jpg new file mode 100644 index 0000000..4cd02a6 Binary files /dev/null and b/public/whack_pack/63.learn_from_mistakes.jpg differ diff --git a/public/whack_pack/64.give_yourself_a_pat_on_the_back.jpg b/public/whack_pack/64.give_yourself_a_pat_on_the_back.jpg new file mode 100644 index 0000000..e401227 Binary files /dev/null and b/public/whack_pack/64.give_yourself_a_pat_on_the_back.jpg differ diff --git a/public/whack_pack/7.see_the_big_picture.jpg b/public/whack_pack/7.see_the_big_picture.jpg new file mode 100644 index 0000000..f171e10 Binary files /dev/null and b/public/whack_pack/7.see_the_big_picture.jpg differ diff --git a/public/whack_pack/8.try_a_random_idea.jpg b/public/whack_pack/8.try_a_random_idea.jpg new file mode 100644 index 0000000..81e1f83 Binary files /dev/null and b/public/whack_pack/8.try_a_random_idea.jpg differ diff --git a/public/whack_pack/9.drop_an_assumption.jpg b/public/whack_pack/9.drop_an_assumption.jpg new file mode 100644 index 0000000..44dab00 Binary files /dev/null and b/public/whack_pack/9.drop_an_assumption.jpg differ diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..81a1087 --- /dev/null +++ b/server/db.js @@ -0,0 +1,601 @@ +import { DatabaseSync } from "node:sqlite"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { getCardByNumber } from "./whack_cards.js"; + +export const VALID_STATUSES = ["spark", "exploring", "building", "shipped", "archived"]; +export const VALID_CATEGORIES = ["project", "creative", "spark", "research"]; + +export function parseQuickCaptureInput(rawInput, defaultCategory = "spark") { + let text = String(rawInput || "").trim(); + const tags = []; + let detectedCategory = null; + let boostExcitement = false; + + // Extract #tags + text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => { + const clean = tag.toLowerCase(); + if (!tags.includes(clean)) tags.push(clean); + return " "; + }); + + // Extract @category + text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, cat) => { + const lower = cat.toLowerCase(); + if (["project", "product", "app", "saas", "startup"].includes(lower)) { + detectedCategory = "project"; + } else if (["creative", "art", "tshirt", "merch", "design", "writing"].includes(lower)) { + detectedCategory = "creative"; + if (["tshirt", "merch"].includes(lower) && !tags.includes(lower)) { + tags.push(lower); + } + } else if (["research", "book", "learn", "inspiration"].includes(lower)) { + detectedCategory = "research"; + } else if (["spark", "thought", "quick"].includes(lower)) { + detectedCategory = "spark"; + } + return " "; + }); + + // Extract !high or !! + if (/(?:^|\s)!(?:high|hot|!)/i.test(text)) { + boostExcitement = true; + text = text.replace(/(?:^|\s)!(?:high|hot|!)/gi, " "); + } + + const cleanTitle = text.replace(/\s+/g, " ").trim() || "Untitled Spark"; + const category = detectedCategory || (VALID_CATEGORIES.includes(defaultCategory) ? defaultCategory : "spark"); + + return { + title: cleanTitle, + category, + tags, + boostExcitement + }; +} + +export function computeCompositeScore(row) { + const excitement = Number(row.rating_excitement ?? 3); + const impact = Number(row.rating_impact ?? 3); + const ease = Number(row.rating_ease ?? 3); + + // Weighted base 0..100 + const base = ((excitement * 0.42 + impact * 0.35 + ease * 0.23) / 5) * 100; + const arenaBonus = Math.min(15, Number(row.arena_wins || 0) * 3); + const whackBonus = Math.min(6, (row.whack_history?.length || 0) * 2); + const mockupBonus = (row.mockups?.length || 0) > 0 ? 4 : 0; + + return Math.min(100, Math.round(base + arenaBonus + whackBonus + mockupBonus)); +} + +export function createDatabase(dbFilePath) { + fs.mkdirSync(path.dirname(dbFilePath), { recursive: true }); + const db = new DatabaseSync(dbFilePath); + + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS ideas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + elevator_pitch TEXT DEFAULT '', + category TEXT DEFAULT 'spark', + status TEXT DEFAULT 'spark', + notes_markdown TEXT DEFAULT '', + tags_json TEXT DEFAULT '[]', + checklist_json TEXT DEFAULT '[]', + pros_json TEXT DEFAULT '[]', + cons_json TEXT DEFAULT '[]', + links_json TEXT DEFAULT '[]', + whack_history_json TEXT DEFAULT '[]', + inspired_by_card INTEGER DEFAULT NULL, + mockups_json TEXT DEFAULT '[]', + cover_mockup_url TEXT DEFAULT '', + rating_excitement INTEGER DEFAULT 3, + rating_impact INTEGER DEFAULT 3, + rating_ease INTEGER DEFAULT 3, + arena_wins INTEGER DEFAULT 0, + arena_matches INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, + last_reviewed_at TEXT DEFAULT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + + function safeParse(jsonStr, fallback = []) { + try { + return jsonStr ? JSON.parse(jsonStr) : fallback; + } catch { + return fallback; + } + } + + function hydrateIdea(row) { + if (!row) return null; + const tags = safeParse(row.tags_json, []); + const checklist = safeParse(row.checklist_json, []); + const pros = safeParse(row.pros_json, []); + const cons = safeParse(row.cons_json, []); + const links = safeParse(row.links_json, []); + const whack_history = safeParse(row.whack_history_json, []); + const mockups = safeParse(row.mockups_json, []); + const inspiredCard = row.inspired_by_card ? getCardByNumber(row.inspired_by_card) : null; + + const hydrated = { + id: row.id, + title: row.title, + elevator_pitch: row.elevator_pitch || "", + category: row.category || "spark", + status: row.status || "spark", + notes_markdown: row.notes_markdown || "", + tags, + checklist, + pros, + cons, + links, + whack_history, + inspired_by_card: row.inspired_by_card || null, + inspired_card_detail: inspiredCard, + mockups, + cover_mockup_url: row.cover_mockup_url || (mockups[0]?.imageUrl || ""), + rating_excitement: Number(row.rating_excitement ?? 3), + rating_impact: Number(row.rating_impact ?? 3), + rating_ease: Number(row.rating_ease ?? 3), + arena_wins: Number(row.arena_wins ?? 0), + arena_matches: Number(row.arena_matches ?? 0), + pinned: Boolean(row.pinned), + last_reviewed_at: row.last_reviewed_at, + created_at: row.created_at, + updated_at: row.updated_at + }; + hydrated.composite_score = computeCompositeScore(hydrated); + return hydrated; + } + + function listIdeas({ status, category, tag, search, hasMockup, sort = "score" } = {}) { + const rows = db.prepare(`SELECT * FROM ideas`).all(); + let items = rows.map(hydrateIdea); + + if (status && status !== "all") { + items = items.filter((i) => i.status === status); + } + if (category && category !== "all") { + items = items.filter((i) => i.category === category); + } + if (tag && tag !== "all") { + items = items.filter((i) => i.tags.includes(tag.toLowerCase())); + } + if (hasMockup === "true" || hasMockup === true) { + items = items.filter((i) => i.mockups.length > 0 || Boolean(i.cover_mockup_url)); + } + if (search && search.trim()) { + const q = search.trim().toLowerCase(); + items = items.filter( + (i) => + i.title.toLowerCase().includes(q) || + i.elevator_pitch.toLowerCase().includes(q) || + i.notes_markdown.toLowerCase().includes(q) || + i.tags.some((t) => t.toLowerCase().includes(q)) + ); + } + + items.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + if (sort === "newest") return b.created_at.localeCompare(a.created_at) || b.id - a.id; + if (sort === "updated") return b.updated_at.localeCompare(a.updated_at) || b.id - a.id; + if (sort === "arena") return b.arena_wins - a.arena_wins || b.composite_score - a.composite_score; + if (sort === "excitement") return b.rating_excitement - a.rating_excitement || b.composite_score - a.composite_score; + return b.composite_score - a.composite_score || b.updated_at.localeCompare(a.updated_at); + }); + + return items; + } + + function getIdea(id) { + const row = db.prepare(`SELECT * FROM ideas WHERE id = ?`).get(Number(id)); + return hydrateIdea(row); + } + + function createIdea(payload = {}) { + const now = new Date().toISOString(); + const parsed = parseQuickCaptureInput(payload.title || "Untitled Spark", payload.category); + + const finalTitle = payload.preserveRawTitle ? String(payload.title).trim() : parsed.title; + const finalCategory = VALID_CATEGORIES.includes(payload.category) ? payload.category : parsed.category; + const finalStatus = VALID_STATUSES.includes(payload.status) ? payload.status : "spark"; + + const mergedTags = Array.from( + new Set([...(Array.isArray(payload.tags) ? payload.tags.map((t) => String(t).toLowerCase()) : []), ...parsed.tags]) + ); + + const excitement = payload.rating_excitement ?? (parsed.boostExcitement ? 5 : 3); + const impact = payload.rating_impact ?? 3; + const ease = payload.rating_ease ?? 3; + const mockups = Array.isArray(payload.mockups) ? payload.mockups : []; + const whackHistory = Array.isArray(payload.whack_history) ? payload.whack_history : []; + const coverMockupUrl = payload.cover_mockup_url || (mockups[0]?.imageUrl ?? ""); + + const stmt = db.prepare(` + INSERT INTO ideas ( + title, elevator_pitch, category, status, notes_markdown, + tags_json, checklist_json, pros_json, cons_json, links_json, + whack_history_json, inspired_by_card, mockups_json, cover_mockup_url, + rating_excitement, rating_impact, rating_ease, + arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + finalTitle, + payload.elevator_pitch || "", + finalCategory, + finalStatus, + payload.notes_markdown || "", + JSON.stringify(mergedTags), + JSON.stringify(Array.isArray(payload.checklist) ? payload.checklist : []), + JSON.stringify(Array.isArray(payload.pros) ? payload.pros : []), + JSON.stringify(Array.isArray(payload.cons) ? payload.cons : []), + JSON.stringify(Array.isArray(payload.links) ? payload.links : []), + JSON.stringify(whackHistory), + payload.inspired_by_card ? Number(payload.inspired_by_card) : null, + JSON.stringify(mockups), + coverMockupUrl, + Number(excitement), + Number(impact), + Number(ease), + Number(payload.arena_wins || 0), + Number(payload.arena_matches || 0), + payload.pinned ? 1 : 0, + payload.last_reviewed_at || null, + now, + now + ); + + return getIdea(result.lastInsertRowid); + } + + function updateIdea(id, patch = {}) { + const existing = getIdea(id); + if (!existing) return null; + + const now = new Date().toISOString(); + const updated = { + title: patch.title !== undefined ? String(patch.title).trim() || existing.title : existing.title, + elevator_pitch: patch.elevator_pitch !== undefined ? String(patch.elevator_pitch) : existing.elevator_pitch, + category: patch.category !== undefined && VALID_CATEGORIES.includes(patch.category) ? patch.category : existing.category, + status: patch.status !== undefined && VALID_STATUSES.includes(patch.status) ? patch.status : existing.status, + notes_markdown: patch.notes_markdown !== undefined ? String(patch.notes_markdown) : existing.notes_markdown, + tags: Array.isArray(patch.tags) ? patch.tags.map((t) => String(t).toLowerCase().trim()).filter(Boolean) : existing.tags, + checklist: Array.isArray(patch.checklist) ? patch.checklist : existing.checklist, + pros: Array.isArray(patch.pros) ? patch.pros : existing.pros, + cons: Array.isArray(patch.cons) ? patch.cons : existing.cons, + links: Array.isArray(patch.links) ? patch.links : existing.links, + whack_history: Array.isArray(patch.whack_history) ? patch.whack_history : existing.whack_history, + inspired_by_card: patch.inspired_by_card !== undefined ? (patch.inspired_by_card ? Number(patch.inspired_by_card) : null) : existing.inspired_by_card, + mockups: Array.isArray(patch.mockups) ? patch.mockups : existing.mockups, + cover_mockup_url: patch.cover_mockup_url !== undefined ? String(patch.cover_mockup_url) : existing.cover_mockup_url, + rating_excitement: patch.rating_excitement !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_excitement))) : existing.rating_excitement, + rating_impact: patch.rating_impact !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_impact))) : existing.rating_impact, + rating_ease: patch.rating_ease !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_ease))) : existing.rating_ease, + arena_wins: patch.arena_wins !== undefined ? Number(patch.arena_wins) : existing.arena_wins, + arena_matches: patch.arena_matches !== undefined ? Number(patch.arena_matches) : existing.arena_matches, + pinned: patch.pinned !== undefined ? (patch.pinned ? 1 : 0) : (existing.pinned ? 1 : 0), + last_reviewed_at: patch.last_reviewed_at !== undefined ? patch.last_reviewed_at : existing.last_reviewed_at, + updated_at: now + }; + + db.prepare(` + UPDATE ideas SET + title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?, + tags_json = ?, checklist_json = ?, pros_json = ?, cons_json = ?, links_json = ?, + whack_history_json = ?, inspired_by_card = ?, mockups_json = ?, cover_mockup_url = ?, + rating_excitement = ?, rating_impact = ?, rating_ease = ?, + arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ? + WHERE id = ? + `).run( + updated.title, + updated.elevator_pitch, + updated.category, + updated.status, + updated.notes_markdown, + JSON.stringify(updated.tags), + JSON.stringify(updated.checklist), + JSON.stringify(updated.pros), + JSON.stringify(updated.cons), + JSON.stringify(updated.links), + JSON.stringify(updated.whack_history), + updated.inspired_by_card, + JSON.stringify(updated.mockups), + updated.cover_mockup_url, + updated.rating_excitement, + updated.rating_impact, + updated.rating_ease, + updated.arena_wins, + updated.arena_matches, + updated.pinned, + updated.last_reviewed_at, + updated.updated_at, + Number(id) + ); + + return getIdea(id); + } + + function deleteIdea(id) { + const info = db.prepare(`DELETE FROM ideas WHERE id = ?`).run(Number(id)); + return info.changes > 0; + } + + function logWhackOnIdea(id, { cardNumber, reflection, addChecklistItem = false, promoteStatus = null }) { + const idea = getIdea(id); + if (!idea) return null; + const card = getCardByNumber(cardNumber); + if (!card) throw new Error(`Invalid Whack Pack card number: ${cardNumber}`); + + const entry = { + id: `whack-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + cardNumber: card.number, + cardTitle: card.title, + suit: card.suit, + suitLabel: card.suitLabel, + promptQuestion: card.promptQuestion, + reflection: String(reflection || "").trim(), + createdAt: new Date().toISOString() + }; + + const nextWhackHistory = [entry, ...idea.whack_history]; + const nextChecklist = [...idea.checklist]; + if (addChecklistItem && entry.reflection) { + nextChecklist.push({ + id: `chk-${Date.now()}`, + text: `[Card #${card.number} ${card.title}] ${entry.reflection}`, + done: false + }); + } + + const patch = { + whack_history: nextWhackHistory, + checklist: nextChecklist, + last_reviewed_at: new Date().toISOString() + }; + if (promoteStatus && VALID_STATUSES.includes(promoteStatus)) { + patch.status = promoteStatus; + } + + return updateIdea(id, patch); + } + + function addMockupToIdea(id, mockupEntry) { + const idea = getIdea(id); + if (!idea) return null; + + const entry = { + id: mockupEntry.id || `mk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + title: mockupEntry.title || `${idea.title} Mockup`, + template: mockupEntry.template || "tshirt-front", + garmentColor: mockupEntry.garmentColor || "#F5E8D3", + garmentColorName: mockupEntry.garmentColorName || "Vintage Cream", + placement: mockupEntry.placement || "center-chest", + prompt: mockupEntry.prompt || "", + editInstruction: mockupEntry.editInstruction || "", + parentMockupId: mockupEntry.parentMockupId || null, + modelUsed: mockupEntry.modelUsed || "canvas-compositor", + imageUrl: mockupEntry.imageUrl, + topText: mockupEntry.topText || "", + bottomText: mockupEntry.bottomText || "", + createdAt: new Date().toISOString() + }; + + const nextMockups = [entry, ...idea.mockups]; + return updateIdea(id, { + mockups: nextMockups, + cover_mockup_url: entry.imageUrl + }); + } + + function recordArenaMatch({ winnerId, loserId }) { + const winner = getIdea(winnerId); + const loser = getIdea(loserId); + if (!winner || !loser) return null; + + const now = new Date().toISOString(); + const updatedWinner = updateIdea(winner.id, { + arena_wins: winner.arena_wins + 1, + arena_matches: winner.arena_matches + 1, + last_reviewed_at: now + }); + const updatedLoser = updateIdea(loser.id, { + arena_matches: loser.arena_matches + 1, + last_reviewed_at: now + }); + + return { winner: updatedWinner, loser: updatedLoser }; + } + + function getSetting(key, defaultValue = "") { + const row = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(String(key)); + return row ? row.value : defaultValue; + } + + function setSetting(key, value) { + db.prepare(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(String(key), String(value ?? "")); + } + + function seedIfEmpty() { + const countRow = db.prepare(`SELECT COUNT(*) AS count FROM ideas`).get(); + if (countRow && countRow.count > 0) return; + + createIdea({ + title: "High Altitude Espresso Club β€” Vintage Screenprint Tee", + elevator_pitch: "Heavyweight cream crewneck t-shirt with a 3-color retro mountain & espresso emblem (5-inch left chest + full back print).", + category: "creative", + status: "building", + rating_excitement: 5, + rating_impact: 4, + rating_ease: 5, + pinned: true, + tags: ["tshirt", "merch", "screenprint", "apparel"], + notes_markdown: "### Garment & Ink Specs\n- **Blank**: Heavyweight 6.5oz combed ring-spun tee in **Vintage Cream (`#F5E8D3`)**.\n- **Ink Palette**: Espresso Brown (`#42200E`), Burnt Orange (`#BC4921`), Warm Gold (`#EFA51C`).\n- **Placement**: Test both **Left-Chest 5-inch emblem** and **Full-Front club crest** in the Nano Banana Mockup Studio.", + checklist: [ + { id: "c1", text: "Generate initial 3-color emblem mockup in Nano Banana Studio", done: true }, + { id: "c2", text: "Compare Left-Chest 5in placement vs Full-Front on Cream tee", done: true }, + { id: "c3", text: "Test Forest Green & Vintage Black colorway variations", done: false }, + { id: "c4", text: "Export 300 DPI print-ready PNG proof", done: false } + ], + pros: [ + "3 spot colors keep screen setup affordable", + "Cream fabric works as the 4th highlight tone without white underbase" + ], + cons: [ + "Small tagline text needs minimum 10pt stroke for clean screen mesh" + ], + inspired_by_card: 14, + whack_history: [ + { + id: "whack-seed-1", + cardNumber: 14, + cardTitle: "Look to the Past", + suit: "explorer", + suitLabel: "Explorer", + promptQuestion: "What vintage craft, historical design, or classic wisdom can you revive or remix here?", + reflection: "Modeled the typography and badge border after 1970s Alpine mountaineering club patches and classic Italian espresso tin labels.", + createdAt: new Date(Date.now() - 86400000 * 2).toISOString() + } + ], + mockups: [ + { + id: "mk-seed-espresso", + title: "Vintage Cream Tee β€” 3-Color Mountain Espresso Crest", + template: "tshirt-front", + garmentColor: "#F5E8D3", + garmentColorName: "Vintage Cream", + placement: "center-chest", + prompt: "Heavyweight vintage cream crewneck t-shirt featuring a 3-color retro screenprint emblem for HIGH ALTITUDE ESPRESSO CLUB - EST. 1984", + modelUsed: "gemini-nano-banana-2.1", + imageUrl: "/mockups/seed-tshirt-espresso.jpg", + createdAt: new Date(Date.now() - 3600000 * 12).toISOString() + } + ], + cover_mockup_url: "/mockups/seed-tshirt-espresso.jpg", + arena_wins: 3, + arena_matches: 3 + }); + + createIdea({ + title: "Take a Whack At It β€” Die-Cut Studio Sticker & Desk Totem", + elevator_pitch: "Die-cut vinyl laptop & sketchbook sticker inspired by Card #49 (Warrior suit) to nudge daily shipping habits.", + category: "creative", + status: "exploring", + rating_excitement: 4, + rating_impact: 4, + rating_ease: 5, + tags: ["sticker", "merch", "whack-pack", "illustration"], + notes_markdown: "Inspired by Roger von Oech's **Warrior Card #49: Take a Whack at It**.\n\nCould be printed as a 3-inch matte vinyl sticker and included as a physical reward whenever a project in the vault moves to **Shipped**.", + checklist: [ + { id: "c21", text: "Mock up die-cut sticker contour with bold typography", done: true }, + { id: "c22", text: "Try a t-shirt pocket variation in the Mockup Studio", done: false } + ], + pros: ["Instant tactile reminder to stop overthinking and build"], + cons: ["Needs high-contrast border for dark laptops"], + inspired_by_card: 49, + whack_history: [ + { + id: "whack-seed-2", + cardNumber: 49, + cardTitle: "Take a Whack at It", + suit: "warrior", + suitLabel: "Warrior", + promptQuestion: "How can you take a concrete whack at your idea in the next 30 minutes?", + reflection: "Turned the lightbulb-boxer metaphor directly into a collectible studio sticker.", + createdAt: new Date(Date.now() - 86400000).toISOString() + } + ], + mockups: [ + { + id: "mk-seed-sticker", + title: "Die-Cut Vinyl Sticker β€” Lightbulb Boxer", + template: "sticker", + garmentColor: "#1C1B1A", + garmentColorName: "Studio Slate", + placement: "die-cut", + prompt: "Die-cut vinyl sticker of a glowing lightbulb wearing red boxing gloves with text TAKE A WHACK AT IT", + modelUsed: "gemini-nano-banana-2.1", + imageUrl: "/mockups/seed-sticker-whack.jpg", + createdAt: new Date(Date.now() - 3600000 * 6).toISOString() + } + ], + cover_mockup_url: "/mockups/seed-sticker-whack.jpg", + arena_wins: 2, + arena_matches: 3 + }); + + createIdea({ + title: "E-Ink Kitchen Recipe & Brew Telemetry Frame", + elevator_pitch: "Low-power wall-mounted e-paper display that shows active fermentation stats by day and switches to dinner recipe cards at 5pm.", + category: "project", + status: "exploring", + rating_excitement: 5, + rating_impact: 5, + rating_ease: 3, + tags: ["hardware", "e-ink", "homelab", "brewing"], + notes_markdown: "Combine a 7.5-inch tri-color E-Ink panel with ESP32.\n- Morning/Afternoon: Ambient dashboard (brew tilt hydrometer gravity + cellar temp).\n- Evening: Pulls tonight's selected recipe card with step-by-step ingredients.", + checklist: [ + { id: "c31", text: "Design 800x480 1-bit wireframe layout", done: false }, + { id: "c32", text: "Test rotary encoder for scrolling recipe steps with floury hands", done: false } + ], + pros: ["Zero screen glare in the kitchen", "Battery lasts months"], + cons: ["Slow refresh rate when switching pages"], + inspired_by_card: 25, + arena_wins: 1, + arena_matches: 2 + }); + + createIdea({ + title: "National Park Night-Sky Astrophotography Hoodie Series", + elevator_pitch: "Minimalist star-chart back print paired with elevation coordinates on the left chest for Maroon Bells & Mount Sneffels.", + category: "creative", + status: "spark", + rating_excitement: 4, + rating_impact: 4, + rating_ease: 4, + tags: ["tshirt", "apparel", "colorado", "astronomy"], + notes_markdown: "Use glow-in-the-dark or metallic silver puff ink on a deep midnight navy garment (`#1B2A4A`). Great candidate to test in the **Nano Banana Mockup Studio**!" + }); + + createIdea({ + title: "Acoustic Vinyl Listening Log & Liner Notes Archive", + elevator_pitch: "Scan album jackets on the turntable and pair them with personal listening memories and pressing notes.", + category: "spark", + status: "spark", + rating_excitement: 3, + rating_impact: 3, + rating_ease: 4, + tags: ["music", "archive", "weekend-project"], + notes_markdown: "Quick sparkβ€”could use a Whack Pack card in the **Idea Collider** to find a unique twist." + }); + } + + seedIfEmpty(); + + return { + db, + listIdeas, + getIdea, + createIdea, + updateIdea, + deleteIdea, + logWhackOnIdea, + addMockupToIdea, + recordArenaMatch, + getSetting, + setSetting + }; +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..0729588 --- /dev/null +++ b/server/index.js @@ -0,0 +1,599 @@ +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDatabase } from "./db.js"; +import { + WHACK_CARDS, + SUIT_META, + getCardByNumber, + drawCards, + drawFourRoleSpread +} from "./whack_cards.js"; +import { + NANO_BANANA_MODELS, + SURFACE_PRESETS, + buildNanoBananaPrompt, + generateWithNanoBanana +} from "./nano_banana.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, ".."); +const PUBLIC_DIR = path.join(ROOT_DIR, "public"); +const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data"); +const ACTIVE_DATA_DIR = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : BUNDLED_DATA_DIR; +const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db"); +const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups"); + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon" +}; + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store" + }); + res.end(body); +} + +function readJsonBody(req, maxBytes = 25 * 1024 * 1024) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on("data", (chunk) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error("Payload too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw ? JSON.parse(raw) : {}); + } catch (err) { + reject(new Error("Invalid JSON body")); + } + }); + req.on("error", reject); + }); +} + +function serveStaticFile(res, filePath) { + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + sendJson(res, 404, { error: "File not found" }); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": contentType }); + fs.createReadStream(filePath).pipe(res); +} + +function ensureSeedMockupsCopied(targetMockupsDir) { + fs.mkdirSync(targetMockupsDir, { recursive: true }); + const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups"); + if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) { + return; + } + for (const file of fs.readdirSync(bundledMockupsDir)) { + const src = path.join(bundledMockupsDir, file); + const dest = path.join(targetMockupsDir, file); + if (!fs.existsSync(dest) && fs.statSync(src).isFile()) { + fs.copyFileSync(src, dest); + } + } +} + +export function createAppServer({ + dbPath = DEFAULT_DB_PATH, + mockupsDir = DEFAULT_MOCKUPS_DIR +} = {}) { + ensureSeedMockupsCopied(mockupsDir); + const store = createDatabase(dbPath); + + const server = http.createServer(async (req, res) => { + try { + const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const pathname = decodeURIComponent(parsedUrl.pathname); + const method = req.method.toUpperCase(); + + // --- HEALTHCHECK --- + if (method === "GET" && pathname === "/api/health") { + sendJson(res, 200, { + status: "ok", + service: "sparkvault", + timestamp: new Date().toISOString() + }); + return; + } + + // --- API ROUTES --- + + // 1. GET /api/ideas + if (method === "GET" && pathname === "/api/ideas") { + const status = parsedUrl.searchParams.get("status") || "all"; + const category = parsedUrl.searchParams.get("category") || "all"; + const tag = parsedUrl.searchParams.get("tag") || "all"; + const search = parsedUrl.searchParams.get("search") || ""; + const hasMockup = parsedUrl.searchParams.get("hasMockup") || ""; + const sort = parsedUrl.searchParams.get("sort") || "score"; + + const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort }); + const allIdeas = store.listIdeas({}); + + const tagCounts = {}; + const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 }; + let totalMockups = 0; + let totalWhacks = 0; + + for (const item of allIdeas) { + if (statusCounts[item.status] !== undefined) statusCounts[item.status]++; + totalMockups += item.mockups.length; + totalWhacks += item.whack_history.length; + for (const t of item.tags) { + tagCounts[t] = (tagCounts[t] || 0) + 1; + } + } + + sendJson(res, 200, { + ideas, + stats: { + total: allIdeas.length, + statusCounts, + totalMockups, + totalWhacks, + tags: Object.entries(tagCounts) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ name, count })) + } + }); + return; + } + + // 2. POST /api/ideas + if (method === "POST" && pathname === "/api/ideas") { + const body = await readJsonBody(req); + if (!body.title || !String(body.title).trim()) { + sendJson(res, 400, { error: "Idea title is required" }); + return; + } + const created = store.createIdea(body); + sendJson(res, 201, { idea: created }); + return; + } + + // 3. GET / PATCH / DELETE /api/ideas/:id + const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/); + if (ideaIdMatch) { + const id = Number(ideaIdMatch[1]); + if (method === "GET") { + const idea = store.getIdea(id); + if (!idea) return sendJson(res, 404, { error: "Idea not found" }); + return sendJson(res, 200, { idea }); + } + if (method === "PATCH") { + const body = await readJsonBody(req); + const updated = store.updateIdea(id, body); + if (!updated) return sendJson(res, 404, { error: "Idea not found" }); + return sendJson(res, 200, { idea: updated }); + } + if (method === "DELETE") { + const deleted = store.deleteIdea(id); + if (!deleted) return sendJson(res, 404, { error: "Idea not found" }); + return sendJson(res, 200, { deleted: true, id }); + } + } + + // 4. GET /api/whack-cards + if (method === "GET" && pathname === "/api/whack-cards") { + const allIdeas = store.listIdeas({}); + const usageByCard = {}; + for (const idea of allIdeas) { + if (idea.inspired_by_card) { + usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1; + } + for (const w of idea.whack_history) { + usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1; + } + } + + const cards = WHACK_CARDS.map((c) => ({ + ...c, + usageCount: usageByCard[c.number] || 0 + })); + + sendJson(res, 200, { + suits: SUIT_META, + cards + }); + return; + } + + // 5. GET /api/whack-cards/draw + if (method === "GET" && pathname === "/api/whack-cards/draw") { + const mode = parsedUrl.searchParams.get("mode") || "single"; + const suit = parsedUrl.searchParams.get("suit") || ""; + const status = parsedUrl.searchParams.get("status") || ""; + const ideaId = parsedUrl.searchParams.get("ideaId"); + + if (mode === "spread") { + const spread = drawFourRoleSpread(); + return sendJson(res, 200, { mode: "spread", cards: spread }); + } + + if (mode === "collider") { + const activeIdeas = store + .listIdeas({}) + .filter((i) => i.status !== "archived" && i.status !== "shipped"); + let targetIdea = null; + if (ideaId) { + targetIdea = store.getIdea(Number(ideaId)); + } + if (!targetIdea && activeIdeas.length > 0) { + targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)]; + } + const drawn = drawCards({ + suit: suit || undefined, + status: targetIdea?.status || undefined, + count: 1 + })[0]; + return sendJson(res, 200, { + mode: "collider", + card: drawn, + idea: targetIdea + }); + } + + const drawn = drawCards({ + suit: suit || undefined, + status: status || undefined, + count: 1 + })[0]; + + sendJson(res, 200, { mode: "single", card: drawn }); + return; + } + + // 6. POST /api/ideas/:id/whack + const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/); + if (method === "POST" && whackMatch) { + const id = Number(whackMatch[1]); + const body = await readJsonBody(req); + if (!body.cardNumber) { + return sendJson(res, 400, { error: "cardNumber is required" }); + } + const updated = store.logWhackOnIdea(id, { + cardNumber: Number(body.cardNumber), + reflection: body.reflection || "", + addChecklistItem: Boolean(body.addChecklistItem), + promoteStatus: body.promoteStatus || null + }); + if (!updated) return sendJson(res, 404, { error: "Idea not found" }); + return sendJson(res, 200, { idea: updated }); + } + + // 7. POST /api/ideas/:id/mockups/nano-banana + const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/); + if (method === "POST" && nbMatch) { + const id = Number(nbMatch[1]); + const idea = store.getIdea(id); + if (!idea) return sendJson(res, 404, { error: "Idea not found" }); + + const body = await readJsonBody(req); + const template = body.template || "tshirt-front"; + const garmentColor = body.garmentColor || "#F5E8D3"; + const garmentColorName = body.garmentColorName || "Vintage Cream"; + const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest"); + const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null; + + // Check if editing an existing mockup + let referenceImagePath = null; + let parentMockupId = body.parentMockupId || null; + if (parentMockupId) { + const parentMockup = idea.mockups.find((m) => m.id === parentMockupId); + if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) { + const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl)); + if (fs.existsSync(candidate)) { + referenceImagePath = candidate; + } + } + } + + const finalPrompt = buildNanoBananaPrompt({ + rawPrompt: body.prompt, + ideaTitle: idea.title, + elevatorPitch: idea.elevator_pitch, + template, + garmentColorName, + garmentHex: garmentColor, + whackCard, + editInstruction: body.editInstruction + }); + + const storedKey = store.getSetting("gemini_api_key", ""); + const selectedModel = body.model || store.getSetting("nano_banana_model", "gemini-nano-banana-2.1"); + + try { + const genResult = await generateWithNanoBanana({ + apiKey: body.apiKey || storedKey, + model: selectedModel, + prompt: finalPrompt, + referenceImagePath, + referenceImageBase64: body.referenceImageBase64 || null, + outputDir: mockupsDir + }); + + const updated = store.addMockupToIdea(id, { + title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`), + template, + garmentColor, + garmentColorName, + placement, + prompt: finalPrompt, + editInstruction: body.editInstruction || "", + parentMockupId, + modelUsed: genResult.modelUsed, + imageUrl: genResult.imageUrl + }); + + return sendJson(res, 201, { + idea: updated, + mockup: updated.mockups[0], + textNotes: genResult.textNotes + }); + } catch (err) { + const status = err.code === "MISSING_API_KEY" ? 400 : 502; + return sendJson(res, status, { + error: err.message || "Nano Banana image generation failed", + code: err.code || "GENERATION_ERROR", + promptBuilt: finalPrompt + }); + } + } + + // 8. POST /api/ideas/:id/mockups/canvas + const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/); + if (method === "POST" && canvasMatch) { + const id = Number(canvasMatch[1]); + const idea = store.getIdea(id); + if (!idea) return sendJson(res, 404, { error: "Idea not found" }); + + const body = await readJsonBody(req); + if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) { + return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" }); + } + + const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); + const filename = `mockup-${id}-${Date.now()}.png`; + const filePath = path.join(mockupsDir, filename); + fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); + + const updated = store.addMockupToIdea(id, { + title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`, + template: body.template || "tshirt-front", + garmentColor: body.garmentColor || "#F5E8D3", + garmentColorName: body.garmentColorName || "Vintage Cream", + placement: body.placement || "center-chest", + prompt: body.prompt || "", + topText: body.topText || "", + bottomText: body.bottomText || "", + modelUsed: body.modelUsed || "Interactive Mockup Compositor", + imageUrl: `/mockups/${filename}` + }); + + return sendJson(res, 201, { + idea: updated, + mockup: updated.mockups[0] + }); + } + + // 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId + const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/); + if (mockupItemMatch) { + const id = Number(mockupItemMatch[1]); + const mockupId = mockupItemMatch[2]; + const isCoverAction = mockupItemMatch[3] === "cover"; + const idea = store.getIdea(id); + if (!idea) return sendJson(res, 404, { error: "Idea not found" }); + + if (method === "PATCH" && isCoverAction) { + const target = idea.mockups.find((m) => m.id === mockupId); + if (!target) return sendJson(res, 404, { error: "Mockup not found" }); + const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl }); + return sendJson(res, 200, { idea: updated }); + } + + if (method === "DELETE" && !isCoverAction) { + const nextMockups = idea.mockups.filter((m) => m.id !== mockupId); + const nextCover = + idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url) + ? idea.cover_mockup_url + : nextMockups[0]?.imageUrl || ""; + const updated = store.updateIdea(id, { + mockups: nextMockups, + cover_mockup_url: nextCover + }); + return sendJson(res, 200, { idea: updated }); + } + } + + // 10. GET /api/review/next & POST /api/review/arena + if (method === "GET" && pathname === "/api/review/next") { + const allIdeas = store.listIdeas({}); + const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped"); + + const triageQueue = [...activeIdeas].sort((a, b) => { + if (!a.last_reviewed_at && b.last_reviewed_at) return -1; + if (a.last_reviewed_at && !b.last_reviewed_at) return 1; + if (a.last_reviewed_at && b.last_reviewed_at) { + return a.last_reviewed_at.localeCompare(b.last_reviewed_at); + } + return a.created_at.localeCompare(b.created_at); + }); + + let arenaPair = []; + if (activeIdeas.length >= 2) { + const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5); + arenaPair = [shuffled[0], shuffled[1]]; + } + + return sendJson(res, 200, { + triageQueue, + arenaPair + }); + } + + if (method === "POST" && pathname === "/api/review/arena") { + const body = await readJsonBody(req); + if (!body.winnerId || !body.loserId) { + return sendJson(res, 400, { error: "winnerId and loserId are required" }); + } + const result = store.recordArenaMatch({ + winnerId: Number(body.winnerId), + loserId: Number(body.loserId) + }); + if (!result) return sendJson(res, 404, { error: "Ideas not found" }); + return sendJson(res, 200, result); + } + + // 11. GET / PUT /api/settings + if (pathname === "/api/settings") { + if (method === "GET") { + const savedKey = store.getSetting("gemini_api_key", ""); + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; + const activeKey = savedKey || envKey; + const model = store.getSetting("nano_banana_model", "gemini-nano-banana-2.1"); + + return sendJson(res, 200, { + hasApiKey: Boolean(activeKey), + apiKeySource: savedKey ? "settings" : envKey ? "environment" : "none", + maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : "", + nanoBananaModel: model, + availableModels: NANO_BANANA_MODELS, + surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({ + id, + label: val.label + })) + }); + } + if (method === "PUT") { + const body = await readJsonBody(req); + if (body.geminiApiKey !== undefined) { + store.setSetting("gemini_api_key", String(body.geminiApiKey).trim()); + } + if (body.nanoBananaModel !== undefined) { + store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim()); + } + const savedKey = store.getSetting("gemini_api_key", ""); + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; + const activeKey = savedKey || envKey; + return sendJson(res, 200, { + saved: true, + hasApiKey: Boolean(activeKey), + nanoBananaModel: store.getSetting("nano_banana_model", "gemini-nano-banana-2.1") + }); + } + } + + // 12. GET /api/export + if (method === "GET" && pathname === "/api/export") { + const format = parsedUrl.searchParams.get("format") || "json"; + const ideas = store.listIdeas({}); + if (format === "markdown") { + const mdLines = [ + `# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`, + "" + ]; + for (const idea of ideas) { + mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`); + if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`); + mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`); + mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`); + if (idea.notes_markdown) { + mdLines.push("", idea.notes_markdown); + } + if (idea.checklist.length > 0) { + mdLines.push("", "### Checklist"); + for (const item of idea.checklist) { + mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`); + } + } + if (idea.whack_history.length > 0) { + mdLines.push("", "### Creative Whack Pack Reflections"); + for (const w of idea.whack_history) { + mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`); + } + } + mdLines.push("", "---", ""); + } + res.writeHead(200, { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": 'attachment; filename="sparkvault-ideas.md"' + }); + res.end(mdLines.join("\n")); + return; + } + + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": 'attachment; filename="sparkvault-ideas.json"' + }); + res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2)); + return; + } + + // --- STATIC ASSETS --- + if (pathname.startsWith("/mockups/")) { + const rel = pathname.slice("/mockups/".length); + const safePath = path.normalize(path.join(mockupsDir, rel)); + if (!safePath.startsWith(mockupsDir)) { + return sendJson(res, 403, { error: "Forbidden" }); + } + return serveStaticFile(res, safePath); + } + + const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath)); + if (!staticPath.startsWith(PUBLIC_DIR)) { + return sendJson(res, 403, { error: "Forbidden" }); + } + if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) { + return serveStaticFile(res, staticPath); + } + + // SPA Fallback + return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html")); + } catch (err) { + console.error("Server error:", err); + sendJson(res, 500, { error: err.message || "Internal server error" }); + } + }); + + return { server, store }; +} + +// Start server if invoked directly +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + const port = Number(process.env.PORT || 3456); + const { server } = createAppServer(); + server.listen(port, () => { + console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`); + }); +} diff --git a/server/nano_banana.js b/server/nano_banana.js new file mode 100644 index 0000000..c1463e0 --- /dev/null +++ b/server/nano_banana.js @@ -0,0 +1,174 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { GoogleGenAI } from "@google/genai"; + +export const NANO_BANANA_MODELS = [ + { + id: "gemini-nano-banana-2.1", + label: "Nano Banana 2.1 (Default High-Efficiency)", + description: "gemini-nano-banana-2.1 β€” Fast, high-efficiency native image generation and conversational editing" + }, + { + id: "gemini-3-pro-image", + label: "Nano Banana Pro (Studio Quality)", + description: "gemini-3-pro-image β€” High-detail studio mockup generation and multi-turn editing" + }, + { + id: "gemini-3.1-flash-lite-image", + label: "Nano Banana 2 Lite (Ultra-Fast)", + description: "gemini-3.1-flash-lite-image β€” Rapid concept sketching and quick iterations" + } +]; + +export const SURFACE_PRESETS = { + "tshirt-front": { + label: "πŸ‘• T-Shirt (Full Front Print)", + buildPrompt: ({ prompt, garmentColorName, garmentHex }) => + `Photorealistic studio flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt. Centered full-front screenprint graphic design: ${prompt}. Crisp fabric texture, authentic screenprint ink detail, clean studio lighting.` + }, + "tshirt-left-chest": { + label: "πŸ‘• T-Shirt (5-inch Left-Chest Emblem)", + buildPrompt: ({ prompt, garmentColorName, garmentHex }) => + `Photorealistic studio closeup & flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt featuring a compact 5-inch left-chest pocket-zone screenprinted emblem: ${prompt}. Clean vector-style spot-color ink on fabric, studio lighting.` + }, + "isolated-graphic": { + label: "🎨 Isolated Screenprint Artwork (Vector Style)", + buildPrompt: ({ prompt, garmentColorName }) => + `Isolated flat vector screenprint t-shirt graphic emblem on a clean solid ${garmentColorName || "cream"} background, no shirt folds, bold spot-color illustration and outlined vintage typography: ${prompt}` + }, + "sticker": { + label: "🏷️ Die-Cut Vinyl Sticker / Patch", + buildPrompt: ({ prompt }) => + `Photorealistic die-cut vinyl sticker with a crisp white contour border resting on a dark slate studio surface. Bold vector illustration and typography: ${prompt}` + }, + "poster": { + label: "πŸ–ΌοΈ Framed Studio Art Poster", + buildPrompt: ({ prompt }) => + `Gallery-framed vertical screenprint poster mockup in a minimalist studio interior. Bold editorial illustration and typography: ${prompt}` + }, + "app-ui": { + label: "πŸ’» Product / App Interface Concept", + buildPrompt: ({ prompt }) => + `High-craft modern product design and UI concept presentation mockup on a sleek dark desktop studio display: ${prompt}` + } +}; + +export function buildNanoBananaPrompt({ + rawPrompt, + ideaTitle, + elevatorPitch, + template = "tshirt-front", + garmentColorName = "Vintage Cream", + garmentHex = "#F5E8D3", + whackCard = null, + editInstruction = null +}) { + if (editInstruction && editInstruction.trim()) { + let editText = editInstruction.trim(); + if (whackCard) { + editText += ` Incorporate creative twist from Card #${whackCard.number} (${whackCard.title}): ${whackCard.promptQuestion}`; + } + return editText; + } + + const baseConcept = (rawPrompt && rawPrompt.trim()) + ? rawPrompt.trim() + : `${ideaTitle}${elevatorPitch ? ` β€” ${elevatorPitch}` : ""}`; + + const surface = SURFACE_PRESETS[template] || SURFACE_PRESETS["tshirt-front"]; + let finalPrompt = surface.buildPrompt({ + prompt: baseConcept, + garmentColorName, + garmentHex + }); + + if (whackCard) { + finalPrompt += ` Creative direction inspired by "${whackCard.title}": ${whackCard.promptQuestion}`; + } + return finalPrompt; +} + +/** + * Generates or edits an image using Google Gemini Nano Banana (`gemini-nano-banana-2.1`) + * via the official `@google/genai` Interactions API (`ai.interactions.create`). + */ +export async function generateWithNanoBanana({ + apiKey, + model = "gemini-nano-banana-2.1", + prompt, + referenceImagePath = null, + referenceImageBase64 = null, + referenceMimeType = "image/png", + outputDir +}) { + const resolvedKey = apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; + if (!resolvedKey) { + const err = new Error( + "No Gemini API key configured. Click 'βš™οΈ Nano Banana API Key' in the Mockup Studio (or set GEMINI_API_KEY) to generate live images with Nano Banana 2.1, or use the Interactive T-Shirt Canvas Compositor." + ); + err.code = "MISSING_API_KEY"; + throw err; + } + + const validModel = NANO_BANANA_MODELS.some((m) => m.id === model) + ? model + : "gemini-nano-banana-2.1"; + + const ai = new GoogleGenAI({ apiKey: resolvedKey }); + + let inputPayload = prompt; + + // Check if we are performing conversational image-to-image editing + let base64Data = referenceImageBase64; + let mimeType = referenceMimeType || "image/png"; + + if (!base64Data && referenceImagePath && fs.existsSync(referenceImagePath)) { + const bytes = fs.readFileSync(referenceImagePath); + base64Data = bytes.toString("base64"); + if (referenceImagePath.endsWith(".jpg") || referenceImagePath.endsWith(".jpeg")) { + mimeType = "image/jpeg"; + } + } + + if (base64Data) { + // Strip data URL prefix if present + const cleanedBase64 = base64Data.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); + inputPayload = [ + { type: "text", text: prompt }, + { + type: "image", + mime_type: mimeType, + data: cleanedBase64 + } + ]; + } + + const interaction = await ai.interactions.create({ + model: validModel, + input: inputPayload + }); + + const generatedImage = interaction.output_image; + if (!generatedImage || !generatedImage.data) { + throw new Error( + interaction.output_text || + "Nano Banana completed the interaction without returning an image block. Try refining your visual prompt." + ); + } + + const ext = (generatedImage.mime_type || "").includes("jpeg") ? "jpg" : "png"; + const filename = `nb-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`; + const fullPath = path.join(outputDir, filename); + + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(fullPath, Buffer.from(generatedImage.data, "base64")); + + return { + filename, + imageUrl: `/mockups/${filename}`, + mimeType: generatedImage.mime_type || `image/${ext}`, + modelUsed: validModel, + interactionId: interaction.id || null, + textNotes: interaction.output_text || null + }; +} diff --git a/server/whack_cards.js b/server/whack_cards.js new file mode 100644 index 0000000..79afa35 --- /dev/null +++ b/server/whack_cards.js @@ -0,0 +1,158 @@ +// Complete 64-Card Creative Whack Pack Catalog (Roger von Oech) +// Mapped to the 4 Creative Roles: Explorer (1-16), Artist (17-32), Judge (33-48), Warrior (49-64) + +export const SUIT_META = { + explorer: { + id: "explorer", + label: "Explorer", + range: "1–16", + color: "#2b7ec1", + roleDescription: "For discovering new territory, gathering raw information, and looking in unexpected places.", + stageAffinity: ["spark", "exploring"] + }, + artist: { + id: "artist", + label: "Artist", + range: "17–32", + color: "#d95f3b", + roleDescription: "For transforming raw material into new ideas through play, reversal, metaphor, and combination.", + stageAffinity: ["spark", "exploring"] + }, + judge: { + id: "judge", + label: "Judge", + range: "33–48", + color: "#2a9158", + roleDescription: "For evaluating ideas critically, testing hidden assumptions, and deciding what is worth building.", + stageAffinity: ["exploring", "building"] + }, + warrior: { + id: "warrior", + label: "Warrior", + range: "49–64", + color: "#b8335a", + roleDescription: "For overcoming excuses, slaying obstacles, and carrying your idea into shipped reality.", + stageAffinity: ["building", "shipped"] + } +}; + +const RAW_CARDS = [ + // EXPLORER (1-16) + { number: 1, file: "1.give_yourself_a_whack_on_the_side_of_the_head.jpg", title: "Give Yourself a Whack on the Side of the Head", promptQuestion: "How can you whack your thinking and break out of habitual routines?" }, + { number: 2, file: "2.ask_why.jpg", title: "Ask Why", promptQuestion: "Why does this problem exist, and what happens if you ask 'Why?' five times in a row?" }, + { number: 3, file: "3.get_out_of_your_box.jpg", title: "Get Out of Your Box", promptQuestion: "What other fields or disciplines outside your specialty can you draw on for this idea?" }, + { number: 4, file: "4.look_somewhere_else.jpg", title: "Look Somewhere Else", promptQuestion: "Where else could you look for inspiration that nobody in this space usually checks?" }, + { number: 5, file: "5.dig_deeper.jpg", title: "Dig Deeper", promptQuestion: "What overlooked details or root mechanics lie beneath the surface of this idea?" }, + { number: 6, file: "6.change_viewpoints.jpg", title: "Change Viewpoints", promptQuestion: "How does this idea look from the perspective of a beginner, a skeptic, or the end user?" }, + { number: 7, file: "7.see_the_big_picture.jpg", title: "See the Big Picture", promptQuestion: "If you zoom out 10x, what larger system or story is this idea really part of?" }, + { number: 8, file: "8.try_a_random_idea.jpg", title: "Try a Random Idea", promptQuestion: "Pick a random object or concept nearbyβ€”how can it force a fresh connection to your idea?" }, + { number: 9, file: "9.drop_an_assumption.jpg", title: "Drop an Assumption", promptQuestion: "What unnecessary boundary or assumption are you taking for grantedβ€”and what if you dropped it?" }, + { number: 10, file: "10.find_a_pattern.jpg", title: "Find a Pattern", promptQuestion: "What recurring theme, rhythm, or pattern connects this idea to other things that work?" }, + { number: 11, file: "11.see_hear_tast_feel_smell.jpg", title: "See, Hear, Taste, Feel, Smell", promptQuestion: "How can you engage more physical senses, textures, or tactile qualities in this concept?" }, + { number: 12, file: "12.listen_to_your_dreams.jpg", title: "Listen to Your Dreams", promptQuestion: "What subconscious hunch, daydream, or wild wish is trying to surface here?" }, + { number: 13, file: "13.look_to_nature.jpg", title: "Look to Nature", promptQuestion: "How has nature or biology already solved a problem similar to yours?" }, + { number: 14, file: "14.look_to_the_past.jpg", title: "Look to the Past", promptQuestion: "What vintage craft, historical design, or classic wisdom can you revive or remix here?" }, + { number: 15, file: "15.let_your_mind_wander.jpg", title: "Let Your Mind Wander", promptQuestion: "If you stop forcing a direct path and let your thoughts drift, what tangent excites you most?" }, + { number: 16, file: "16.see_the_obvious.jpg", title: "See the Obvious", promptQuestion: "What simple, right-in-front-of-your-nose truth are you overcomplicating?" }, + + // ARTIST (17-32) + { number: 17, file: "17.think_like_a_kid.jpg", title: "Think Like a Kid", promptQuestion: "What would a six-year-old see, ask, or play with if they looked at your project?" }, + { number: 18, file: "18.ask_what_if.jpg", title: "Ask What If", promptQuestion: "What if the rules of gravity, cost, or convention didn't applyβ€”what 'What if?' unlocks this?" }, + { number: 19, file: "19.challenge_the_rules.jpg", title: "Challenge the Rules", promptQuestion: "Which sacred rule in this category is ripe to be broken or inverted?" }, + { number: 20, file: "20.be_whacky.jpg", title: "Be Whacky", promptQuestion: "What is the most absurd, playful, or outrageous version of this idea?" }, + { number: 21, file: "21.simplify.jpg", title: "Simplify", promptQuestion: "What can you strip away so only the purest, most essential core remains?" }, + { number: 22, file: "22.exaggerate.jpg", title: "Exaggerate", promptQuestion: "What happens if you magnify one feature 100x or shrink it to miniature scale?" }, + { number: 23, file: "23.rearrange.jpg", title: "Rearrange", promptQuestion: "How can you swap the order, layout, or hierarchy of the components?" }, + { number: 24, file: "24.substitute.jpg", title: "Substitute", promptQuestion: "What material, medium, setting, or ingredient could you swap in for a surprising twist?" }, + { number: 25, file: "25.combine_ideas.jpg", title: "Combine Ideas", promptQuestion: "What two unrelated concepts or sparks can you fuse together into a hybrid?" }, + { number: 26, file: "26.make_a_metaphor.jpg", title: "Make a Metaphor", promptQuestion: "If this idea were a physical object, ritual, or creature, what metaphor captures it best?" }, + { number: 27, file: "27.reverse.jpg", title: "Reverse", promptQuestion: "What happens if you flip this idea inside-out, upside-down, or backwards?" }, + { number: 28, file: "28.imagine_how_others_woud_do_it.jpg", title: "Imagine How Others Would Do It", promptQuestion: "How would your favorite artist, designer, or historical figure tackle this idea?" }, + { number: 29, file: "29.imagine_youre_the_idea.jpg", title: "Imagine You're the Idea", promptQuestion: "If you were this idea personified, what would you want to become next?" }, + { number: 30, file: "30.change_its_name.jpg", title: "Change Its Name", promptQuestion: "What new name, title, or slogan would completely reframe how people experience this?" }, + { number: 31, file: "31.dont_force_it.jpg", title: "Don't Force It", promptQuestion: "Where are you pushing too hard, and how can you let the natural form of the idea emerge?" }, + { number: 32, file: "32.pause_for_a_bit.jpg", title: "Pause for a Bit", promptQuestion: "What part of this idea needs a brief incubation period so a better angle can click?" }, + + // JUDGE (33-48) + { number: 33, file: "33.see_the_positive.jpg", title: "See the Positive", promptQuestion: "What is genuinely strong, exciting, and worth protecting about this idea right now?" }, + { number: 34, file: "34.find_out_whats_out_of_whack.jpg", title: "Find Out What's Out of Whack", promptQuestion: "What feels slightly off-balance, awkward, or friction-heavy in the current concept?" }, + { number: 35, file: "35.solve_the_right_problem.jpg", title: "Solve the Right Problem", promptQuestion: "Are you solving the real core problem, or just polishing a symptom?" }, + { number: 36, file: "36.conform.jpg", title: "Conform", promptQuestion: "Where should you actually follow familiar standards so users immediately understand it?" }, + { number: 37, file: "37.ask_a_fool.jpg", title: "Ask a Fool", promptQuestion: "What naive or blunt question would an outsider ask that exposes a blind spot?" }, + { number: 38, file: "38.focus_on_the_real_truth.jpg", title: "Focus on the Real Truth", promptQuestion: "Strip away wishful thinkingβ€”what do the real constraints and facts say?" }, + { number: 39, file: "39.slay_a_sacred_cow.jpg", title: "Slay a Sacred Cow", promptQuestion: "What darling feature or habit are you keeping out of nostalgia that should be cut?" }, + { number: 40, file: "40.loosen_up.jpg", title: "Loosen Up", promptQuestion: "Are you judging this idea too harshly before giving it room to breathe?" }, + { number: 41, file: "41.check_your_timing.jpg", title: "Check Your Timing", promptQuestion: "Why is right now (or later) the right moment to bring this idea to life?" }, + { number: 42, file: "42.beware_the_unintended.jpg", title: "Beware the Unintended", promptQuestion: "What second-order side effects or unintended consequences could this create?" }, + { number: 43, file: "43.get_out_of_the_dogma_house.jpg", title: "Get Out of the Dogma House", promptQuestion: "Where is rigid ideology getting in the way of a practical, creative solution?" }, + { number: 44, file: "44.be_dissatisfied.jpg", title: "Be Dissatisfied", promptQuestion: "What 'good enough' part of this idea deserves to be pushed from decent to remarkable?" }, + { number: 45, file: "45.dont_fall_in_love_with_ideas.jpg", title: "Don't Fall in Love with Ideas", promptQuestion: "If you had to pivot 50% of this concept today, what would you willingly let go of?" }, + { number: 46, file: "46.avoid_arrogance.jpg", title: "Avoid Arrogance", promptQuestion: "Where might your assumptions be wrong, and how can you test them cheaply?" }, + { number: 47, file: "47.listen_to_that_hunch.jpg", title: "Listen to That Hunch", promptQuestion: "What quiet gut instinct do you have about this idea that logic hasn't caught up to yet?" }, + { number: 48, file: "48.hear_the_knock_of_opportunity.jpg", title: "Hear the Knock of Opportunity", promptQuestion: "What unexpected side-benefit or spin-off opportunity is hiding inside this idea?" }, + + // WARRIOR (49-64) + { number: 49, file: "49.take_a_whack_at_it.jpg", title: "Take a Whack at It", promptQuestion: "How can you take a concrete whack at your idea in the next 30 minutes?" }, + { number: 50, file: "50.believe_in_yourself.jpg", title: "Believe in Yourself", promptQuestion: "Why are you uniquely equipped to bring this specific idea into the world?" }, + { number: 51, file: "51.put_a_lion_in_your_heart.jpg", title: "Put a Lion in Your Heart", promptQuestion: "What bold move would you make on this project if you weren't worried about criticism?" }, + { number: 52, file: "52.fight_for_it.jpg", title: "Fight for It", promptQuestion: "What is the #1 obstacle blocking this idea, and how will you push through it?" }, + { number: 53, file: "53.flex_your_risk_muscle.jpg", title: "Flex Your Risk Muscle", promptQuestion: "What calculated risk or experiment can you run right now to move this forward?" }, + { number: 54, file: "54.get_support.jpg", title: "Get Support", promptQuestion: "Who could you show this mockup or prototype to for feedback or collaboration?" }, + { number: 55, file: "55.get_rid_of_excuses.jpg", title: "Get Rid of Excuses", promptQuestion: "What excuse has kept this idea sitting on the shelfβ€”and how can you eliminate it today?" }, + { number: 56, file: "56.slay_a_dragon.jpg", title: "Slay a Dragon", promptQuestion: "What is the hardest, scariest task on this idea's checklist that you should tackle first?" }, + { number: 57, file: "57.have_something_at_stake.jpg", title: "Have Something at Stake", promptQuestion: "How can you put real skin in the game so you're committed to shipping this?" }, + { number: 58, file: "58.use_your_shield.jpg", title: "Use Your Shield", promptQuestion: "How can you protect this fledgling idea from premature naysayers while you build v1?" }, + { number: 59, file: "59.do_the_unexpected.jpg", title: "Do the Unexpected", promptQuestion: "How can you launch, present, or prototype this in a way that surprises everyone?" }, + { number: 60, file: "60.sell_sell_sell.jpg", title: "Sell, Sell, Sell", promptQuestion: "How can you pitch the core value of this idea in one irresistible sentence or visual?" }, + { number: 61, file: "61.set_a_deadline.jpg", title: "Set a Deadline", promptQuestion: "By what exact date and time will you ship a tangible prototype or mockup of this idea?" }, + { number: 62, file: "62.be_persistent.jpg", title: "Be Persistent", promptQuestion: "What small daily or weekly step will keep momentum alive until this crosses the finish line?" }, + { number: 63, file: "63.learn_from_mistakes.jpg", title: "Learn from Mistakes", promptQuestion: "What past stumble or failed attempt can you turn into fuel for this version?" }, + { number: 64, file: "64.give_yourself_a_pat_on_the_back.jpg", title: "Give Yourself a Pat on the Back", promptQuestion: "What progress have you already made on this idea that deserves recognition?" } +]; + +export const WHACK_CARDS = RAW_CARDS.map((card) => { + let suitKey = "explorer"; + if (card.number >= 17 && card.number <= 32) suitKey = "artist"; + else if (card.number >= 33 && card.number <= 48) suitKey = "judge"; + else if (card.number >= 49) suitKey = "warrior"; + + const suit = SUIT_META[suitKey]; + return { + ...card, + suit: suit.id, + suitLabel: suit.label, + color: suit.color, + roleDescription: suit.roleDescription, + stageAffinity: suit.stageAffinity, + imageUrl: `/whack_pack/${card.file}` + }; +}); + +export function getCardByNumber(num) { + return WHACK_CARDS.find((c) => c.number === Number(num)) || null; +} + +export function drawCards({ suit, status, count = 1, excludeNumbers = [] } = {}) { + let pool = WHACK_CARDS.filter((c) => !excludeNumbers.includes(c.number)); + if (suit && SUIT_META[suit]) { + pool = pool.filter((c) => c.suit === suit); + } else if (status) { + if (status === "spark") { + pool = pool.filter((c) => c.suit === "explorer" || c.suit === "artist"); + } else if (status === "exploring") { + pool = pool.filter((c) => c.suit === "artist" || c.suit === "judge"); + } else if (status === "building") { + pool = pool.filter((c) => c.suit === "judge" || c.suit === "warrior"); + } else if (status === "shipped") { + pool = pool.filter((c) => c.suit === "warrior"); + } + } + if (pool.length === 0) pool = [...WHACK_CARDS]; + + const shuffled = [...pool].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.max(1, Math.min(count, shuffled.length))); +} + +export function drawFourRoleSpread() { + return ["explorer", "artist", "judge", "warrior"].map((suit) => drawCards({ suit, count: 1 })[0]); +} diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..f8f90c2 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,184 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { fileURLToPath } from "node:url"; +import { createAppServer } from "../server/index.js"; +import { WHACK_CARDS } from "../server/whack_cards.js"; +import { buildNanoBananaPrompt } from "../server/nano_banana.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PUBLIC_WHACK_DIR = path.resolve(__dirname, "..", "public", "whack_pack"); + +let server; +let baseUrl; +let tmpDir; + +before(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sparkvault-test-")); + const dbPath = path.join(tmpDir, "test-ideas.db"); + const mockupsDir = path.join(tmpDir, "mockups"); + + const app = createAppServer({ dbPath, mockupsDir }); + server = app.server; + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); +}); + +after(async () => { + if (server) { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test("All 64 Creative Whack Pack cards are indexed and have local JPG scans in public/whack_pack", async () => { + assert.equal(WHACK_CARDS.length, 64, "Should have exactly 64 Creative Whack Pack cards"); + + const suitsCount = { explorer: 0, artist: 0, judge: 0, warrior: 0 }; + for (const card of WHACK_CARDS) { + suitsCount[card.suit]++; + const diskPath = path.join(PUBLIC_WHACK_DIR, card.file); + assert.ok(fs.existsSync(diskPath), `Missing card scan on disk: ${card.file}`); + assert.ok(card.promptQuestion.length > 10, `Card #${card.number} should have a guiding promptQuestion`); + } + + assert.deepEqual(suitsCount, { + explorer: 16, + artist: 16, + judge: 16, + warrior: 16 + }); + + const res = await fetch(`${baseUrl}/api/whack-cards`); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.cards.length, 64); +}); + +test("Quick Capture parses inline #tags, @category (@tshirt), and !high priority boost", async () => { + const res = await fetch(`${baseUrl}/api/ideas`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Retro Red Rocks Amphitheatre Tee @tshirt #colorado #screenprint !high" + }) + }); + assert.equal(res.status, 201); + const { idea } = await res.json(); + + assert.equal(idea.title, "Retro Red Rocks Amphitheatre Tee"); + assert.equal(idea.category, "creative"); + assert.equal(idea.status, "spark"); + assert.equal(idea.rating_excitement, 5); + assert.ok(idea.tags.includes("tshirt")); + assert.ok(idea.tags.includes("colorado")); + assert.ok(idea.tags.includes("screenprint")); +}); + +test("Creative Whack Pack Draw, 4-Role Spread, Idea Collider, and Logging a Whack reflection work", async () => { + // 1. 4-Role Spread + const spreadRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=spread`); + assert.equal(spreadRes.status, 200); + const spreadData = await spreadRes.json(); + assert.equal(spreadData.cards.length, 4); + assert.deepEqual( + spreadData.cards.map((c) => c.suit), + ["explorer", "artist", "judge", "warrior"] + ); + + // 2. Idea Collider + const colliderRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=collider&suit=artist`); + assert.equal(colliderRes.status, 200); + const colliderData = await colliderRes.json(); + assert.equal(colliderData.card.suit, "artist"); + assert.ok(colliderData.idea && colliderData.idea.id); + + // 3. Log a Whack breakthrough onto the collided idea + const targetId = colliderData.idea.id; + const whackRes = await fetch(`${baseUrl}/api/ideas/${targetId}/whack`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cardNumber: 27, + reflection: "Reverse the color scheme: print cream ink onto a heavyweight forest green shirt.", + addChecklistItem: true + }) + }); + assert.equal(whackRes.status, 200); + const whackUpdated = await whackRes.json(); + assert.equal(whackUpdated.idea.whack_history[0].cardNumber, 27); + assert.equal(whackUpdated.idea.whack_history[0].cardTitle, "Reverse"); + assert.ok( + whackUpdated.idea.checklist.some((c) => c.text.includes("[Card #27 Reverse]")), + "Should append reflection to checklist when addChecklistItem is true" + ); +}); + +test("Visual Mockup Studio saves canvas T-shirt mockups and builds Nano Banana 2.1 prompts", async () => { + // Verify Nano Banana prompt builder for Left-Chest T-Shirt + Whack Card + const prompt = buildNanoBananaPrompt({ + rawPrompt: "Minimalist Alpine Ibex emblem in burnt orange and gold", + ideaTitle: "Ibex Mountain Tee", + template: "tshirt-left-chest", + garmentColorName: "Vintage Cream", + garmentHex: "#F5E8D3", + whackCard: WHACK_CARDS[21] // #22 Exaggerate + }); + assert.ok(prompt.includes("5-inch left-chest")); + assert.ok(prompt.includes("Vintage Cream")); + assert.ok(prompt.includes("Exaggerate")); + + // Save a 1x1 PNG data URL via /api/ideas/1/mockups/canvas + const samplePngDataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + + const saveRes = await fetch(`${baseUrl}/api/ideas/1/mockups/canvas`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Left-Chest Cream Proof", + template: "tshirt-left-chest", + garmentColor: "#F5E8D3", + garmentColorName: "Vintage Cream", + placement: "left-chest", + imageDataUrl: samplePngDataUrl + }) + }); + assert.equal(saveRes.status, 201); + const saveData = await saveRes.json(); + assert.equal(saveData.mockup.placement, "left-chest"); + assert.equal(saveData.idea.cover_mockup_url, saveData.mockup.imageUrl); + + // Verify the saved mockup image is served over HTTP + const imgRes = await fetch(`${baseUrl}${saveData.mockup.imageUrl}`); + assert.equal(imgRes.status, 200); + assert.equal(imgRes.headers.get("content-type"), "image/png"); +}); + +test("Head-to-Head Arena and Triage Review endpoints update arena_wins and composite_score", async () => { + const revRes = await fetch(`${baseUrl}/api/review/next`); + assert.equal(revRes.status, 200); + const revData = await revRes.json(); + assert.ok(revData.triageQueue.length >= 2); + assert.equal(revData.arenaPair.length, 2); + + const [first, second] = revData.arenaPair; + const beforeWins = first.arena_wins; + + const arenaRes = await fetch(`${baseUrl}/api/review/arena`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ winnerId: first.id, loserId: second.id }) + }); + assert.equal(arenaRes.status, 200); + const arenaData = await arenaRes.json(); + assert.equal(arenaData.winner.arena_wins, beforeWins + 1); +});