Initial commit: SparkVault Idea Tracker, 64-card Creative Whack Pack & Nano Banana 2.1 Mockup Studio
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
data/ideas.db*
|
||||||
|
test
|
||||||
|
.git
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
data/ideas.db
|
||||||
|
data/ideas.db-shm
|
||||||
|
data/ideas.db-wal
|
||||||
|
data/mockups/nb-*
|
||||||
|
data/mockups/mockup-*
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 797 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SparkVault">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#1E2536"/>
|
||||||
|
<stop offset="100%" stop-color="#0E121B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="spark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FDE047"/>
|
||||||
|
<stop offset="50%" stop-color="#F59E0B"/>
|
||||||
|
<stop offset="100%" stop-color="#EA580C"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Rounded Studio Badge -->
|
||||||
|
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="1"/>
|
||||||
|
|
||||||
|
<!-- 4 Whack Pack Suit Accent Dots (Explorer, Artist, Judge, Warrior) -->
|
||||||
|
<circle cx="14" cy="14" r="3" fill="#2B7EC1"/>
|
||||||
|
<circle cx="50" cy="14" r="3" fill="#D95F3B"/>
|
||||||
|
<circle cx="14" cy="50" r="3" fill="#2A9158"/>
|
||||||
|
<circle cx="50" cy="50" r="3" fill="#B8335A"/>
|
||||||
|
|
||||||
|
<!-- Tilted Creative Whack Card Silhouette behind the Spark -->
|
||||||
|
<g transform="rotate(-10 32 32)">
|
||||||
|
<rect x="17" y="12" width="28" height="38" rx="4" fill="#FBF8F1" stroke="#D8CCB8" stroke-width="1.5"/>
|
||||||
|
<line x1="21" y1="19" x2="39" y2="19" stroke="#2B7EC1" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<line x1="21" y1="43" x2="35" y2="43" stroke="#D95F3B" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Central Glowing Idea Spark / Lightning Bulb -->
|
||||||
|
<polygon points="36,11 21,34 31,34 27,53 45,28 34,28" fill="url(#spark)" stroke="#FFFBEB" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,694 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SparkVault — Idea Tracker, Creative Whack Pack & Nano Banana Mockup Studio</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- TOP HEADER & QUICK CAPTURE BAR -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<div class="brand-logo" aria-hidden="true">⚡</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title-row">
|
||||||
|
<h1 class="brand-title">SparkVault</h1>
|
||||||
|
<span class="brand-badge">Whack Pack + 🍌 Nano Banana 2.1</span>
|
||||||
|
</div>
|
||||||
|
<p class="brand-subtitle">Capture sparks, break blocks with 64 Creative Whack cards, and mock up visuals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instant Quick Capture Command Bar -->
|
||||||
|
<form id="quick-capture-form" class="quick-capture-bar" autocomplete="off">
|
||||||
|
<span class="qc-icon" title="Quick Capture (Ctrl+K)">✨</span>
|
||||||
|
<input
|
||||||
|
id="quick-capture-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Quick capture an idea... e.g. Retro Ski Club Tee @creative #tshirt !high (Ctrl+K)"
|
||||||
|
aria-label="Quick capture idea"
|
||||||
|
/>
|
||||||
|
<select id="quick-capture-category" aria-label="Idea category">
|
||||||
|
<option value="spark">⚡ Spark</option>
|
||||||
|
<option value="creative">🎨 Creative / Merch</option>
|
||||||
|
<option value="project">🛠️ Project / App</option>
|
||||||
|
<option value="research">🔬 Research</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Capture</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="btn-quick-whack-prompt"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
title="Draw a random Creative Whack Pack card to spark a new idea"
|
||||||
|
>
|
||||||
|
🃏 Whack Prompt
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button type="button" id="btn-open-settings" class="btn btn-ghost btn-sm" title="Configure Nano Banana 2.1 API Key & Model">
|
||||||
|
🍌 <span id="api-key-status-dot" class="status-dot"></span> Nano Banana
|
||||||
|
</button>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button type="button" id="btn-export-menu" class="btn btn-ghost btn-sm">⬇️ Export</button>
|
||||||
|
<div id="export-dropdown-menu" class="dropdown-menu hidden">
|
||||||
|
<a href="/api/export?format=json" download="sparkvault-ideas.json" class="dropdown-item">📦 Export JSON Vault</a>
|
||||||
|
<a href="/api/export?format=markdown" download="sparkvault-ideas.md" class="dropdown-item">📝 Export Markdown Book</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-theme-toggle" class="btn btn-ghost btn-sm" title="Switch Dark / Warm Paper Theme">
|
||||||
|
☀️ Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- OPTIONAL QUICK WHACK SPARK BANNER -->
|
||||||
|
<section id="quick-whack-banner" class="whack-prompt-banner hidden" aria-live="polite">
|
||||||
|
<div class="whack-banner-inner">
|
||||||
|
<img id="qwb-card-img" src="" alt="Whack Card" class="qwb-thumb" />
|
||||||
|
<div class="qwb-content">
|
||||||
|
<div class="qwb-meta">
|
||||||
|
<span id="qwb-suit-badge" class="suit-pill">Explorer</span>
|
||||||
|
<strong id="qwb-card-title">Card #1</strong>
|
||||||
|
</div>
|
||||||
|
<p id="qwb-card-question" class="qwb-question"></p>
|
||||||
|
</div>
|
||||||
|
<div class="qwb-actions">
|
||||||
|
<button type="button" id="btn-qwb-use" class="btn btn-primary btn-sm">✨ Create Idea from Card</button>
|
||||||
|
<button type="button" id="btn-qwb-redraw" class="btn btn-ghost btn-sm">🔄 Draw Another</button>
|
||||||
|
<button type="button" id="btn-qwb-close" class="btn btn-ghost btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- NAVIGATION & STATS STRIP -->
|
||||||
|
<nav class="subnav">
|
||||||
|
<div class="view-tabs" role="tablist">
|
||||||
|
<button type="button" class="view-tab active" data-view="board" role="tab">
|
||||||
|
📋 Pipeline Board
|
||||||
|
</button>
|
||||||
|
<button type="button" class="view-tab" data-view="grid" role="tab">
|
||||||
|
🗂️ Explorer & Mockup Gallery
|
||||||
|
</button>
|
||||||
|
<button type="button" class="view-tab" data-view="whack" role="tab">
|
||||||
|
🃏 Creative Whack Pack <span class="tab-pill">64 Cards</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="view-tab" data-view="review" role="tab">
|
||||||
|
⚔️ Review & Head-to-Head Arena
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar" id="global-filter-bar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="filter-search"
|
||||||
|
class="filter-input"
|
||||||
|
placeholder="Search ideas, notes, #tags..."
|
||||||
|
/>
|
||||||
|
<select id="filter-category" class="filter-select" aria-label="Filter by category">
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
<option value="creative">🎨 Creative / Merch</option>
|
||||||
|
<option value="project">🛠️ Project / Product</option>
|
||||||
|
<option value="spark">⚡ Quick Spark</option>
|
||||||
|
<option value="research">🔬 Research</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-tag" class="filter-select" aria-label="Filter by tag">
|
||||||
|
<option value="all">All #Tags</option>
|
||||||
|
</select>
|
||||||
|
<label class="filter-checkbox">
|
||||||
|
<input type="checkbox" id="filter-has-mockup" />
|
||||||
|
<span>🍌 Has Mockup (<span id="stat-mockups-count">0</span>)</span>
|
||||||
|
</label>
|
||||||
|
<select id="filter-sort" class="filter-select" aria-label="Sort ideas">
|
||||||
|
<option value="score">Sort: Priority Score</option>
|
||||||
|
<option value="excitement">Sort: Excitement</option>
|
||||||
|
<option value="arena">Sort: Arena Wins</option>
|
||||||
|
<option value="updated">Sort: Recently Updated</option>
|
||||||
|
<option value="newest">Sort: Newest First</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- MAIN WORKSPACE -->
|
||||||
|
<main class="workspace">
|
||||||
|
<!-- VIEW 1: PIPELINE KANBAN BOARD -->
|
||||||
|
<section id="view-board" class="view-section active">
|
||||||
|
<div class="kanban-board" id="kanban-board">
|
||||||
|
<div class="kanban-column" data-status="spark">
|
||||||
|
<div class="kanban-col-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-dot status-spark"></span>
|
||||||
|
<h2>✨ Spark</h2>
|
||||||
|
<span class="role-hint">🧭 Explorer 1–16</span>
|
||||||
|
</div>
|
||||||
|
<span class="col-count" id="count-spark">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-dropzone" data-status="spark" id="col-spark"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column" data-status="exploring">
|
||||||
|
<div class="kanban-col-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-dot status-exploring"></span>
|
||||||
|
<h2>🔍 Exploring</h2>
|
||||||
|
<span class="role-hint">🎨 Artist & ⚖️ Judge</span>
|
||||||
|
</div>
|
||||||
|
<span class="col-count" id="count-exploring">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-dropzone" data-status="exploring" id="col-exploring"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column" data-status="building">
|
||||||
|
<div class="kanban-col-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-dot status-building"></span>
|
||||||
|
<h2>🛠️ Building</h2>
|
||||||
|
<span class="role-hint">⚔️ Warrior 49–64</span>
|
||||||
|
</div>
|
||||||
|
<span class="col-count" id="count-building">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-dropzone" data-status="building" id="col-building"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column" data-status="shipped">
|
||||||
|
<div class="kanban-col-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-dot status-shipped"></span>
|
||||||
|
<h2>🚀 Shipped</h2>
|
||||||
|
<span class="role-hint">Launched</span>
|
||||||
|
</div>
|
||||||
|
<span class="col-count" id="count-shipped">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-dropzone" data-status="shipped" id="col-shipped"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column" data-status="archived">
|
||||||
|
<div class="kanban-col-header">
|
||||||
|
<div>
|
||||||
|
<span class="status-dot status-archived"></span>
|
||||||
|
<h2>📦 Archived</h2>
|
||||||
|
<span class="role-hint">Parked Vault</span>
|
||||||
|
</div>
|
||||||
|
<span class="col-count" id="count-archived">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-dropzone" data-status="archived" id="col-archived"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- VIEW 2: EXPLORER GRID & MOCKUP GALLERY -->
|
||||||
|
<section id="view-grid" class="view-section hidden">
|
||||||
|
<div class="grid-toolbar">
|
||||||
|
<div class="status-pills" id="grid-status-pills">
|
||||||
|
<button type="button" class="pill-btn active" data-status="all">All Stages</button>
|
||||||
|
<button type="button" class="pill-btn" data-status="spark">✨ Spark</button>
|
||||||
|
<button type="button" class="pill-btn" data-status="exploring">🔍 Exploring</button>
|
||||||
|
<button type="button" class="pill-btn" data-status="building">🛠️ Building</button>
|
||||||
|
<button type="button" class="pill-btn" data-status="shipped">🚀 Shipped</button>
|
||||||
|
<button type="button" class="pill-btn" data-status="archived">📦 Archived</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-new-idea-modal" class="btn btn-primary btn-sm">+ New Detailed Idea</button>
|
||||||
|
</div>
|
||||||
|
<div id="explorer-grid" class="explorer-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- VIEW 3: CREATIVE WHACK PACK STUDIO (64 CARDS) -->
|
||||||
|
<section id="view-whack" class="view-section hidden">
|
||||||
|
<div class="whack-studio-header">
|
||||||
|
<div>
|
||||||
|
<h2>🃏 Creative Whack Pack Catalyst Studio</h2>
|
||||||
|
<p>64 lateral-thinking strategies by Roger von Oech across 4 creative roles: <strong>🧭 Explorer (1–16)</strong>, <strong>🎨 Artist (17–32)</strong>, <strong>⚖️ Judge (33–48)</strong>, and <strong>⚔️ Warrior (49–64)</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="whack-mode-tabs">
|
||||||
|
<button type="button" class="pill-btn active" data-whack-mode="collider">⚡ Idea Collider (Card + Idea)</button>
|
||||||
|
<button type="button" class="pill-btn" data-whack-mode="spread">🎴 4-Role Creative Spread</button>
|
||||||
|
<button type="button" class="pill-btn" data-whack-mode="browser">📚 Browse All 64 Cards</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUB-MODE A: IDEA COLLIDER -->
|
||||||
|
<div id="whack-submode-collider" class="whack-submode">
|
||||||
|
<div class="collider-controls">
|
||||||
|
<div class="collider-control-group">
|
||||||
|
<label for="collider-suit-select">Filter Suit:</label>
|
||||||
|
<select id="collider-suit-select" class="filter-select">
|
||||||
|
<option value="">Stage-Matched / Any Suit</option>
|
||||||
|
<option value="explorer">🧭 Explorer (1–16: Gather & Discover)</option>
|
||||||
|
<option value="artist">🎨 Artist (17–32: Play & Transform)</option>
|
||||||
|
<option value="judge">⚖️ Judge (33–48: Evaluate & Test)</option>
|
||||||
|
<option value="warrior">⚔️ Warrior (49–64: Act & Ship)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="collider-control-group">
|
||||||
|
<label for="collider-idea-select">Target Idea:</label>
|
||||||
|
<select id="collider-idea-select" class="filter-select"></select>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-deal-collider" class="btn btn-primary">
|
||||||
|
🎲 Collide Card + Idea
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-collider-random-both" class="btn btn-ghost">
|
||||||
|
🔀 Random Card & Random Spark
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="collider-stage" class="collider-stage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUB-MODE B: 4-ROLE SPREAD -->
|
||||||
|
<div id="whack-submode-spread" class="whack-submode hidden">
|
||||||
|
<div class="spread-bar">
|
||||||
|
<p>Deal one card from each role (<strong>Explorer → Artist → Judge → Warrior</strong>) to walk an idea from raw spark to shipped execution.</p>
|
||||||
|
<button type="button" id="btn-deal-spread" class="btn btn-primary">🎴 Deal Fresh 4-Role Spread</button>
|
||||||
|
</div>
|
||||||
|
<div id="spread-grid" class="spread-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUB-MODE C: 64-CARD DECK BROWSER -->
|
||||||
|
<div id="whack-submode-browser" class="whack-submode hidden">
|
||||||
|
<div class="deck-filter-bar">
|
||||||
|
<div class="suit-filter-buttons" id="deck-suit-filters">
|
||||||
|
<button type="button" class="pill-btn active" data-suit="all">All 64 Cards</button>
|
||||||
|
<button type="button" class="pill-btn suit-explorer-btn" data-suit="explorer">🧭 Explorer (1–16)</button>
|
||||||
|
<button type="button" class="pill-btn suit-artist-btn" data-suit="artist">🎨 Artist (17–32)</button>
|
||||||
|
<button type="button" class="pill-btn suit-judge-btn" data-suit="judge">⚖️ Judge (33–48)</button>
|
||||||
|
<button type="button" class="pill-btn suit-warrior-btn" data-suit="warrior">⚔️ Warrior (49–64)</button>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="deck-search-input" class="filter-input" placeholder="Search 64 cards by title, number, or prompt..." />
|
||||||
|
</div>
|
||||||
|
<div id="deck-cards-grid" class="deck-cards-grid"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- VIEW 4: REVIEW & HEAD-TO-HEAD ARENA -->
|
||||||
|
<section id="view-review" class="view-section hidden">
|
||||||
|
<div class="review-header">
|
||||||
|
<div>
|
||||||
|
<h2>⚔️ Idea Resurfacing & Prioritization Studio</h2>
|
||||||
|
<p>Prevent the idea graveyard: pit ideas head-to-head to find what excites you most, or triage older sparks one card at a time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="review-mode-tabs">
|
||||||
|
<button type="button" class="pill-btn active" data-review-mode="arena">⚔️ Head-to-Head Arena</button>
|
||||||
|
<button type="button" class="pill-btn" data-review-mode="triage">🃏 Stale Spark Triage Deck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="review-submode-arena" class="review-submode">
|
||||||
|
<div class="arena-instructions">
|
||||||
|
<span>Which idea excites you more to work on or prototype next?</span>
|
||||||
|
<button type="button" id="btn-skip-arena" class="btn btn-ghost btn-sm">🔀 Skip / Deal New Pair</button>
|
||||||
|
</div>
|
||||||
|
<div id="arena-stage" class="arena-stage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="review-submode-triage" class="review-submode hidden">
|
||||||
|
<div id="triage-stage" class="triage-stage"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- SLIDE-OVER RICH IDEA WORKBENCH & NANO BANANA MOCKUP STUDIO -->
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop hidden"></div>
|
||||||
|
<aside id="idea-drawer" class="idea-drawer hidden" aria-label="Idea Workbench">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<div class="drawer-title-group">
|
||||||
|
<input type="text" id="drawer-title-input" class="drawer-title-input" placeholder="Idea Title" />
|
||||||
|
<div class="drawer-meta-row">
|
||||||
|
<select id="drawer-status-select" class="drawer-select">
|
||||||
|
<option value="spark">✨ Spark</option>
|
||||||
|
<option value="exploring">🔍 Exploring</option>
|
||||||
|
<option value="building">🛠️ Building</option>
|
||||||
|
<option value="shipped">🚀 Shipped</option>
|
||||||
|
<option value="archived">📦 Archived</option>
|
||||||
|
</select>
|
||||||
|
<select id="drawer-category-select" class="drawer-select">
|
||||||
|
<option value="creative">🎨 Creative / Merch</option>
|
||||||
|
<option value="project">🛠️ Project / Product</option>
|
||||||
|
<option value="spark">⚡ Quick Spark</option>
|
||||||
|
<option value="research">🔬 Research</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="drawer-pin-btn" class="btn btn-ghost btn-sm" title="Pin Idea">📌 Pin</button>
|
||||||
|
<span id="drawer-score-badge" class="score-badge">Score: 75</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-header-actions">
|
||||||
|
<button type="button" id="drawer-delete-btn" class="btn btn-danger-ghost btn-sm" title="Delete Idea">🗑️</button>
|
||||||
|
<button type="button" id="drawer-close-btn" class="btn btn-ghost btn-sm" title="Close (Esc)">✕ Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DRAWER SUB-TABS -->
|
||||||
|
<div class="drawer-tabs">
|
||||||
|
<button type="button" class="drawer-tab active" data-drawer-tab="details">
|
||||||
|
📝 Workbench & Notes
|
||||||
|
</button>
|
||||||
|
<button type="button" class="drawer-tab" data-drawer-tab="mockup">
|
||||||
|
🍌 Nano Banana Mockup Studio <span id="drawer-mockup-count" class="tab-pill">0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="drawer-tab" data-drawer-tab="whack">
|
||||||
|
🃏 Whack This Idea <span id="drawer-whack-count" class="tab-pill">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-body">
|
||||||
|
<!-- TAB 1: DETAILS, NOTES, CHECKLIST, PROS/CONS, RATINGS -->
|
||||||
|
<div id="drawer-tab-details" class="drawer-tab-panel active">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="drawer-pitch-input">Elevator Pitch (One-sentence essence)</label>
|
||||||
|
<input type="text" id="drawer-pitch-input" class="text-input" placeholder="What makes this idea compelling in one sentence?" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prioritization Sliders -->
|
||||||
|
<div class="ratings-card">
|
||||||
|
<div class="rating-control">
|
||||||
|
<label for="slider-excitement">🔥 Excitement: <strong id="val-excitement">3</strong>/5</label>
|
||||||
|
<input type="range" id="slider-excitement" min="1" max="5" step="1" value="3" />
|
||||||
|
</div>
|
||||||
|
<div class="rating-control">
|
||||||
|
<label for="slider-impact">🎯 Impact: <strong id="val-impact">3</strong>/5</label>
|
||||||
|
<input type="range" id="slider-impact" min="1" max="5" step="1" value="3" />
|
||||||
|
</div>
|
||||||
|
<div class="rating-control">
|
||||||
|
<label for="slider-ease">⚡ Ease / Feasibility: <strong id="val-ease">3</strong>/5</label>
|
||||||
|
<input type="range" id="slider-ease" min="1" max="5" step="1" value="3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Tags</label>
|
||||||
|
<div class="tags-editor">
|
||||||
|
<div id="drawer-tags-list" class="tags-list"></div>
|
||||||
|
<form id="drawer-add-tag-form" class="inline-add-form">
|
||||||
|
<input type="text" id="drawer-new-tag-input" class="text-input input-sm" placeholder="Add #tag..." />
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">+ Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown Notes -->
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-header-row">
|
||||||
|
<label for="drawer-notes-input">Specs, Brainstorm & Markdown Notes</label>
|
||||||
|
<button type="button" id="btn-toggle-md-preview" class="btn btn-ghost btn-xs">👁️ Toggle Preview</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="drawer-notes-input" rows="6" class="textarea-input" placeholder="Write specs, fabric/ink colors, architecture notes, or markdown lists..."></textarea>
|
||||||
|
<div id="drawer-notes-preview" class="markdown-preview hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Checklist -->
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="field-header-row">
|
||||||
|
<label>Action Checklist</label>
|
||||||
|
<span id="drawer-checklist-progress" class="muted-label">0/0 completed</span>
|
||||||
|
</div>
|
||||||
|
<div id="drawer-checklist-items" class="checklist-container"></div>
|
||||||
|
<form id="drawer-add-checklist-form" class="inline-add-form">
|
||||||
|
<input type="text" id="drawer-new-checklist-input" class="text-input input-sm" placeholder="Add next concrete step..." />
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm">+ Add Step</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pros & Cons Matrix -->
|
||||||
|
<div class="pros-cons-grid">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>✅ Pros / Why It Works</label>
|
||||||
|
<ul id="drawer-pros-list" class="bullet-list pros-list"></ul>
|
||||||
|
<form id="drawer-add-pro-form" class="inline-add-form">
|
||||||
|
<input type="text" id="drawer-new-pro-input" class="text-input input-sm" placeholder="Add a pro..." />
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">+</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>⚠️ Cons / Risks & Constraints</label>
|
||||||
|
<ul id="drawer-cons-list" class="bullet-list cons-list"></ul>
|
||||||
|
<form id="drawer-add-con-form" class="inline-add-form">
|
||||||
|
<input type="text" id="drawer-new-con-input" class="text-input input-sm" placeholder="Add a risk or constraint..." />
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">+</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference Links -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label>🔗 Reference Links & Inspiration</label>
|
||||||
|
<div id="drawer-links-list" class="links-list"></div>
|
||||||
|
<form id="drawer-add-link-form" class="inline-add-form">
|
||||||
|
<input type="text" id="drawer-link-title" class="text-input input-sm" placeholder="Label (e.g. Blank Shirt Spec)" />
|
||||||
|
<input type="url" id="drawer-link-url" class="text-input input-sm" placeholder="https://..." />
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">+ Add Link</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 2: NANO BANANA 2.1 VISUAL MOCKUP STUDIO -->
|
||||||
|
<div id="drawer-tab-mockup" class="drawer-tab-panel hidden">
|
||||||
|
<div class="mockup-studio-layout">
|
||||||
|
<!-- Left: Generator & Interactive T-Shirt / Product Compositor -->
|
||||||
|
<div class="mockup-controls-pane">
|
||||||
|
<div class="studio-card">
|
||||||
|
<div class="studio-card-header">
|
||||||
|
<h3>🍌 Nano Banana 2.1 AI Mockup Generator</h3>
|
||||||
|
<button type="button" id="btn-studio-auto-prompt" class="btn btn-ghost btn-xs">✨ Auto-Prompt from Idea</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div>
|
||||||
|
<label for="nb-template-select">Product / Surface Preset</label>
|
||||||
|
<select id="nb-template-select" class="filter-select full-width">
|
||||||
|
<option value="tshirt-front">👕 T-Shirt (Full-Front Graphic)</option>
|
||||||
|
<option value="tshirt-left-chest">👕 T-Shirt (5-in Left-Chest Emblem)</option>
|
||||||
|
<option value="isolated-graphic">🎨 Isolated Screenprint Vector Artwork</option>
|
||||||
|
<option value="sticker">🏷️ Die-Cut Vinyl Sticker / Patch</option>
|
||||||
|
<option value="poster">🖼️ Framed Art Poster</option>
|
||||||
|
<option value="app-ui">💻 Product / App Interface</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="nb-whack-twist-select">Optional Whack Pack Twist</label>
|
||||||
|
<select id="nb-whack-twist-select" class="filter-select full-width">
|
||||||
|
<option value="">None (Direct Concept)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Garment / Surface Color Swatches -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Garment / Surface Color: <strong id="nb-selected-color-label">Vintage Cream (#F5E8D3)</strong></label>
|
||||||
|
<div class="color-swatch-row" id="nb-color-swatches">
|
||||||
|
<button type="button" class="color-swatch active" data-color="#F5E8D3" data-name="Vintage Cream" style="background:#F5E8D3" title="Vintage Cream (#F5E8D3)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#1C1B1A" data-name="Vintage Black" style="background:#1C1B1A" title="Vintage Black (#1C1B1A)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#1B2A4A" data-name="Midnight Navy" style="background:#1B2A4A" title="Midnight Navy (#1B2A4A)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#1E3F2B" data-name="Forest Pine" style="background:#1E3F2B" title="Forest Pine (#1E3F2B)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#A8422B" data-name="Terracotta Rust" style="background:#A8422B" title="Terracotta Rust (#A8422B)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#C8C9CC" data-name="Heather Grey" style="background:#C8C9CC" title="Heather Grey (#C8C9CC)"></button>
|
||||||
|
<button type="button" class="color-swatch" data-color="#FFFFFF" data-name="Crisp White" style="background:#FFFFFF" title="Crisp White (#FFFFFF)"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="nb-prompt-input">Visual Description / Artwork Prompt</label>
|
||||||
|
<textarea id="nb-prompt-input" rows="3" class="textarea-input" placeholder="Describe the graphic, emblem, ink colors, typography, or mockup scene..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Iterative Image-to-Image Edit Banner (Active when a mockup is selected for refinement) -->
|
||||||
|
<div id="nb-edit-mode-box" class="nb-edit-box hidden">
|
||||||
|
<div class="nb-edit-header">
|
||||||
|
<span>🍌 <strong>Iterative Edit Mode:</strong> Refining <em id="nb-edit-target-label">Selected Mockup</em></span>
|
||||||
|
<button type="button" id="btn-nb-clear-edit" class="btn btn-ghost btn-xs">Cancel Edit Mode</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nb-edit-instruction-input"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="Tell Nano Banana what to change (e.g. 'Move graphic to left-chest pocket on a forest green shirt')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="studio-action-row">
|
||||||
|
<button type="button" id="btn-generate-nano-banana" class="btn btn-primary">
|
||||||
|
🍌 Generate with Nano Banana 2.1
|
||||||
|
</button>
|
||||||
|
<span id="nb-generation-status" class="muted-label"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interactive T-Shirt & Product Canvas Compositor -->
|
||||||
|
<div class="studio-card">
|
||||||
|
<div class="studio-card-header">
|
||||||
|
<h3>👕 Interactive T-Shirt & Emblem Compositor</h3>
|
||||||
|
<span class="muted-label">Instant apparel layout & print proof</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row-3">
|
||||||
|
<div>
|
||||||
|
<label for="comp-surface-select">Mockup Surface</label>
|
||||||
|
<select id="comp-surface-select" class="filter-select full-width">
|
||||||
|
<option value="tshirt">👕 Crewneck T-Shirt</option>
|
||||||
|
<option value="sticker">🏷️ Die-Cut Sticker / Badge</option>
|
||||||
|
<option value="poster">🖼️ Framed Print</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="comp-placement-select">Print Placement</label>
|
||||||
|
<select id="comp-placement-select" class="filter-select full-width">
|
||||||
|
<option value="center-chest">Full Center Chest</option>
|
||||||
|
<option value="left-chest">5-inch Left-Chest Pocket</option>
|
||||||
|
<option value="oversized">Oversized Vintage Front</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="comp-emblem-select">Vector Emblem</label>
|
||||||
|
<select id="comp-emblem-select" class="filter-select full-width">
|
||||||
|
<option value="mountains">🏔️ Mountain & Sun</option>
|
||||||
|
<option value="espresso">☕ Espresso Cup</option>
|
||||||
|
<option value="lightbulb">💡 Lightbulb Boxer</option>
|
||||||
|
<option value="compass">🧭 Explorer Compass</option>
|
||||||
|
<option value="lightning">⚡ Spark Bolt</option>
|
||||||
|
<option value="none">No Icon (Text/Image Only)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div>
|
||||||
|
<label for="comp-top-text">Top Headline / Club Name</label>
|
||||||
|
<input type="text" id="comp-top-text" class="text-input input-sm" placeholder="HIGH ALTITUDE CLUB" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="comp-bottom-text">Bottom Tagline / Est.</label>
|
||||||
|
<input type="text" id="comp-bottom-text" class="text-input input-sm" placeholder="EST. 1984 • COLORADO" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row-2">
|
||||||
|
<div>
|
||||||
|
<label for="comp-ink-color">Primary Screenprint Ink</label>
|
||||||
|
<input type="color" id="comp-ink-color" value="#BC4921" class="color-picker-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="comp-upload-art">Overlay Custom PNG/JPG Graphic</label>
|
||||||
|
<input type="file" id="comp-upload-art" accept="image/*" class="file-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-preview-wrapper">
|
||||||
|
<canvas id="mockup-compositor-canvas" width="680" height="680"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="studio-action-row">
|
||||||
|
<button type="button" id="btn-save-canvas-mockup" class="btn btn-secondary">
|
||||||
|
📸 Save Canvas Mockup to Idea
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-download-canvas-png" class="btn btn-ghost btn-sm">
|
||||||
|
⬇️ Download PNG
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-clear-uploaded-art" class="btn btn-ghost btn-sm">
|
||||||
|
Clear Overlay Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Saved Mockup Gallery for this Idea -->
|
||||||
|
<div class="mockup-gallery-pane">
|
||||||
|
<div class="studio-card">
|
||||||
|
<div class="studio-card-header">
|
||||||
|
<h3>🖼️ Idea Mockup Gallery</h3>
|
||||||
|
<span class="muted-label">Click 'Refine with 🍌' to iterate on any mockup</span>
|
||||||
|
</div>
|
||||||
|
<div id="drawer-mockup-gallery" class="drawer-mockup-gallery"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 3: WHACK THIS IDEA -->
|
||||||
|
<div id="drawer-tab-whack" class="drawer-tab-panel hidden">
|
||||||
|
<div class="drawer-whack-controls">
|
||||||
|
<div>
|
||||||
|
<h3>🃏 Give This Idea a Creative Whack</h3>
|
||||||
|
<p class="muted-label">Draw a stage-matched card from your 64-card deck to challenge assumptions or spark a new visual direction.</p>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-whack-actions">
|
||||||
|
<select id="drawer-whack-suit-select" class="filter-select">
|
||||||
|
<option value="">Stage-Matched Suit (Recommended)</option>
|
||||||
|
<option value="explorer">🧭 Explorer (1–16)</option>
|
||||||
|
<option value="artist">🎨 Artist (17–32)</option>
|
||||||
|
<option value="judge">⚖️ Judge (33–48)</option>
|
||||||
|
<option value="warrior">⚔️ Warrior (49–64)</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="btn-drawer-draw-whack" class="btn btn-primary btn-sm">
|
||||||
|
🃏 Draw Whack Card
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="drawer-active-whack-stage" class="drawer-whack-stage"></div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<h4>📜 Whack Breakthrough Log for This Idea</h4>
|
||||||
|
<div id="drawer-whack-history-list" class="whack-history-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MODAL: NANO BANANA 2.1 SETTINGS -->
|
||||||
|
<div id="settings-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>🍌 Nano Banana 2.1 (Gemini Image API) Settings</h3>
|
||||||
|
<button type="button" id="btn-close-settings" class="btn btn-ghost btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
<form id="settings-form" class="modal-body">
|
||||||
|
<p class="muted-label">
|
||||||
|
SparkVault uses Google's <strong>Nano Banana 2.1 (`gemini-nano-banana-2.1`)</strong> via the official `@google/genai` Interactions API for text-to-image product mockups and multi-turn conversational image editing.
|
||||||
|
</p>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="settings-api-key">Gemini API Key (`GEMINI_API_KEY`)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="settings-api-key"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="Paste your AI Studio / Gemini API key (AIza...)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<small id="settings-key-status" class="muted-label"></small>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="settings-model-select">Nano Banana Model</label>
|
||||||
|
<select id="settings-model-select" class="filter-select full-width">
|
||||||
|
<option value="gemini-nano-banana-2.1">gemini-nano-banana-2.1 — Nano Banana 2.1 (Default High-Efficiency)</option>
|
||||||
|
<option value="gemini-3-pro-image">gemini-3-pro-image — Nano Banana Pro (High-Detail Studio)</option>
|
||||||
|
<option value="gemini-3.1-flash-lite-image">gemini-3.1-flash-lite-image — Nano Banana 2 Lite (Ultra-Fast)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="btn-cancel-settings" class="btn btn-ghost">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: CARD / MOCKUP LIGHTBOX INSPECTOR -->
|
||||||
|
<div id="lightbox-modal" class="modal-backdrop hidden">
|
||||||
|
<div class="lightbox-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="lightbox-title">Card Preview</h3>
|
||||||
|
<button type="button" id="btn-close-lightbox" class="btn btn-ghost btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lightbox-body">
|
||||||
|
<img id="lightbox-img" src="" alt="Preview" />
|
||||||
|
<div id="lightbox-meta" class="lightbox-meta"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||