diff --git a/.gitignore b/.gitignore index d0fefbf..6d8cbe9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ prototype/data/ .claude/settings.local.json .DS_Store +app/node_modules/ +app/data/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..243f7e2 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,15 @@ +# bms-backbone — Studio Notebook (Postgres + Prisma, plain Node server) +FROM node:22-bookworm + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +COPY prisma ./prisma +RUN npx prisma generate + +COPY . . + +ENV NODE_ENV=production +EXPOSE 3000 +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/app/docker-entrypoint.sh b/app/docker-entrypoint.sh new file mode 100755 index 0000000..3662af7 --- /dev/null +++ b/app/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +echo "==> prisma db push (schema sync)" +npx prisma db push --skip-generate +echo "==> owner seed" +node scripts/seed-owner.mjs +echo "==> starting bms-backbone" +exec node server.mjs diff --git a/app/lib/version.mjs b/app/lib/version.mjs new file mode 100644 index 0000000..0ba6e37 --- /dev/null +++ b/app/lib/version.mjs @@ -0,0 +1 @@ +export const APP_VERSION = "0.2.0"; diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..db159bf --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,451 @@ +{ + "name": "bms-backbone", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bms-backbone", + "version": "0.2.0", + "dependencies": { + "@prisma/client": "^6.7.0", + "bcryptjs": "^3.0.2" + }, + "devDependencies": { + "prisma": "^6.7.0" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", + "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..d139ec8 --- /dev/null +++ b/app/package.json @@ -0,0 +1,17 @@ +{ + "name": "bms-backbone", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "db:push": "prisma db push" + }, + "dependencies": { + "@prisma/client": "^6.7.0", + "bcryptjs": "^3.0.2" + }, + "devDependencies": { + "prisma": "^6.7.0" + } +} diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma new file mode 100644 index 0000000..23dc4e8 --- /dev/null +++ b/app/prisma/schema.prisma @@ -0,0 +1,160 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + passwordHash String + role String @default("OWNER") + created DateTime @default(now()) +} + +model Note { + id Int @id @default(autoincrement()) + text String + tags String @default("") + created DateTime @default(now()) +} + +model Todo { + id Int @id @default(autoincrement()) + text String + done Boolean @default(false) + parentId Int? + created DateTime @default(now()) +} + +model Shopping { + id Int @id @default(autoincrement()) + text String + done Boolean @default(false) + created DateTime @default(now()) +} + +model Glaze { + id Int @id @default(autoincrement()) + name String + kind String @default("glaze") + cone String @default("") + atmosphere String @default("") + surface String @default("") + source String @default("mine") + sg String @default("") + swatch String @default("") + created DateTime @default(now()) + materials GlazeMaterial[] + journal GlazeJournal[] +} + +model GlazeMaterial { + id Int @id @default(autoincrement()) + glazeId Int + glaze Glaze @relation(fields: [glazeId], references: [id], onDelete: Cascade) + name String + pct Float + addition Boolean @default(false) +} + +model GlazeJournal { + id Int @id @default(autoincrement()) + glazeId Int + glaze Glaze @relation(fields: [glazeId], references: [id], onDelete: Cascade) + text String + created DateTime @default(now()) +} + +model Firing { + id Int @id @default(autoincrement()) + type String @default("glaze") + cone String @default("∆6") + schedule String @default("") + status String @default("firing") + resultNotes String @default("") + started DateTime @default(now()) + unloaded DateTime? + items FiringItem[] +} + +model FiringItem { + id Int @id @default(autoincrement()) + firingId Int + firing Firing @relation(fields: [firingId], references: [id], onDelete: Cascade) + piece String + clay String @default("") + glaze String @default("") + rating String @default("") + note String @default("") +} + +model Photo { + id Int @id @default(autoincrement()) + entity String + entityId Int + filename String + created DateTime @default(now()) +} + +model FileAsset { + id Int @id @default(autoincrement()) + entity String @default("") + entityId Int @default(0) + stored String + name String + tag String @default("") + size Int @default(0) + created DateTime @default(now()) +} + +model Product { + id Int @id @default(autoincrement()) + name String + kind String @default("core line") + category String @default("") + price Float @default(0) + cost Float @default(0) + qty Int @default(0) + status String @default("in progress") + notes String @default("") + created DateTime @default(now()) + sales Sale[] +} + +model Sale { + id Int @id @default(autoincrement()) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + qty Int @default(1) + price Float @default(0) + cost Float @default(0) + created DateTime @default(now()) +} + +model Supplier { + id Int @id @default(autoincrement()) + name String + kind String @default("online") + distance String @default("") + url String @default("") + notes String @default("") + searchUrl String @default("") + created DateTime @default(now()) + prices PriceEntry[] +} + +model PriceEntry { + id Int @id @default(autoincrement()) + item String + supplierId Int + supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade) + price Float + unit String @default("") + shipping Float @default(0) + note String @default("") + created DateTime @default(now()) +} diff --git a/app/public/app.js b/app/public/app.js new file mode 100644 index 0000000..00e2d2f --- /dev/null +++ b/app/public/app.js @@ -0,0 +1,618 @@ +// Studio Notebook prototype — vanilla JS, no build step +const $ = (s, el = document) => el.querySelector(s); +const view = $("#view"); +const api = async (path, body) => { + const r = await fetch(path, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined); + if (r.status === 401) { location.href = "/login"; return new Promise(() => {}); } + return r.json(); +}; +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const hour = new Date().getHours(); +$("#greeting").textContent = (hour < 12 ? "good morning" : hour < 17 ? "good afternoon" : "good evening") + ", Bonna"; + +let current = "today"; +let subState = null; // inline to-do entry: {anchor: parent todo id, last: last-added id} +document.querySelectorAll("#nav button").forEach((b) => + b.addEventListener("click", () => { + document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("on", x === b)); + current = b.dataset.v; + render(); + })); + +function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); } + +// ── jot box (shared) ───────────────────────────────────────────── +function captureHTML(kinds) { + const chips = kinds.map(([k, label], i) => `${label}`).join(""); + return `
+ +
+
file it as…${chips}
+
⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot
`; +} +function wireCapture(after) { + const jot = $("#jot"); + const chips = [...document.querySelectorAll(".capture .chip")]; + const select = (c) => chips.forEach((x) => x.classList.toggle("on", x === c)); + chips.forEach((c) => c.addEventListener("click", () => select(c))); + const save = async () => { + const text = jot.value.trim(); + if (!text) return; + const kind = $(".capture .chip.on")?.dataset.kind || "note"; + await api("/api/jot", { text, kind }); + await after(); + const j = $("#jot"); + if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting + }; + $("#jot-save").addEventListener("click", save); + jot.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } + else if (e.key === "Enter" && e.shiftKey) { autogrow(); } + else if (e.key === "Tab") { + e.preventDefault(); + const i = chips.findIndex((c) => c.classList.contains("on")); + select(chips[(i + (e.shiftKey ? chips.length - 1 : 1)) % chips.length]); + } + }); + const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; }); + jot.addEventListener("input", autogrow); +} +// "/" from anywhere jumps to the jot box +document.addEventListener("keydown", (e) => { + if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) { + const j = $("#jot"); + if (j) { e.preventDefault(); j.focus(); } + } +}); + +// ── Today ──────────────────────────────────────────────────────── +function todoRow(t, sub) { + return `
+ + ${sub ? "↳ " : ""}${esc(t.text)} + ${sub ? "" : ``}
+ ${sub ? "" : ``}`; +} +async function today() { + const s = await api("/api/summary"); + const parents = s.todos.filter((t) => !t.parent_id); + const kids = (id) => s.todos.filter((t) => t.parent_id === id).sort((a, b) => a.id - b.id); + const open = parents.filter((t) => !t.done || kids(t.id).some((k) => !k.done)); + const rows = (list) => list.map((t) => todoRow(t) + kids(t.id).map((k) => todoRow(k, true)).join("")).join(""); + const todoRows = rows(open.slice(0, 14)) || `
nothing yet — jot one above ✨
`; + const shopRows = s.shopping.map((t) => + `
${esc(t.text)}
`).join("") || `
list is empty — lucky you 🧺
`; + const noteRows = s.notes.map((n) => `
“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”
`).join("") || `
no notes yet
`; + const kiln = s.activeFiring + ? `
🔥 Firing #${s.activeFiring.id} in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}
+
` + : `
Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}
`; + view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"]]) + + `
+

To-do

${todoRows}
+

Shopping list 🧺

${shopRows}
+

Kiln

${kiln}
+

Recent notes

${noteRows}
+
`; + wireCapture(today); + // outliner-style inline entry: ⇧Enter = another subtask, Enter = new top-level + // task (input follows it), ⌫ on empty = undo last add, Esc closes + const openForm = () => { + if (!subState) return; + const form = view.querySelector(`[data-subform="${subState.anchor}"]`); + if (!form) { subState = null; return; } + form.hidden = false; + form.querySelector("input").focus(); + }; + openForm(); + view.querySelectorAll("[data-sub]").forEach((b) => + b.addEventListener("click", () => { + const id = Number(b.dataset.sub); + subState = subState?.anchor === id ? null : { anchor: id, last: null }; + view.querySelectorAll(".subform").forEach((f) => (f.hidden = true)); + openForm(); + })); + view.querySelectorAll("[data-subform] input").forEach((inp) => + inp.addEventListener("keydown", async (e) => { + const text = inp.value.trim(); + if (e.key === "Enter" && e.shiftKey && text) { + e.preventDefault(); + const r = await api("/api/todos/add", { text, parent_id: subState.anchor }); + subState.last = r.id; + today(); + } else if (e.key === "Enter" && !e.shiftKey && text) { + e.preventDefault(); + const r = await api("/api/todos/add", { text }); + subState = { anchor: r.id, last: r.id }; + today(); + } else if (e.key === "Backspace" && !inp.value && subState?.last) { + e.preventDefault(); + const undoing = subState.last; + await api("/api/todos/delete", { id: undoing }); + subState = undoing === subState.anchor ? null : { ...subState, last: null }; + today(); + } else if (e.key === "Escape" || (e.key === "Enter" && !text)) { + subState = null; + today(); + } + })); + view.querySelectorAll("[data-t]").forEach((c) => c.addEventListener("change", async () => { await api("/api/todos/toggle", { id: Number(c.dataset.t) }); today(); })); + view.querySelectorAll("[data-s]").forEach((c) => c.addEventListener("change", async () => { await api("/api/shopping/toggle", { id: Number(c.dataset.s) }); today(); })); + $("#goto-firing")?.addEventListener("click", () => $('#nav button[data-v="firings"]').click()); +} + +// ── Glazes ─────────────────────────────────────────────────────── +const SWATCHES = ["#8fb0a3", "#c98d5f", "#cfc8ba", "#7f9bb3", "#b3849a", "#8a8f6a", "#6e4f78", "#c9b8a4"]; +async function glazes() { + const list = await api("/api/glazes"); + const card = (g) => ` +
+
+

${esc(g.name)}

+
${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}
+
`; + const section = (label) => `

${label}

`; + const glazeCards = list.filter((g) => !g.kind || g.kind === "glaze").map(card).join(""); + const slipCards = list.filter((g) => g.kind === "casting slip").map(card).join(""); + const clayCards = list.filter((g) => g.kind === "clay body").map(card).join(""); + view.innerHTML = ` +
+
${glazeCards || '
no glazes yet — add your first 🧪
'}
+ ${slipCards ? section("Casting slips 🏺") + `
${slipCards}
` : ""} + ${clayCards ? section("Clay bodies 🧱") + `
${clayCards}
` : ""}`; + $("#new-glaze").addEventListener("click", newGlazeForm); + view.querySelectorAll("[data-g]").forEach((c) => c.addEventListener("click", () => glazeDetail(Number(c.dataset.g)))); +} + +function newGlazeForm() { + const chip = (group, vals, on) => vals.map((v) => `${v}`).join(""); + view.innerHTML = ` +
+

New glaze

+
Only the name is required — everything else can arrive later.
+
+
+
${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}
+
${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}
+
${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}
+
${chip("surface", ["glossy", "satin", "matte"], "")}
+
${chip("source", ["mine", "Glazy", "book / friend"], "mine")}
+
${SWATCHES.map((s, i) => ``).join("")}
+
+
+ +
+ +
`; + const mats = $("#mats"); + const addRow = (addition) => { + const row = document.createElement("div"); + row.className = "matrow"; + row.innerHTML = ``; + mats.appendChild(row); + }; + addRow(false); addRow(false); addRow(false); + $("#add-mat").addEventListener("click", () => addRow(false)); + $("#add-addition").addEventListener("click", () => addRow(true)); + view.querySelectorAll(".chip[data-group]").forEach((c) => + c.addEventListener("click", () => { + view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c)); + })); + $("#back").addEventListener("click", glazes); + $("#save-glaze").addEventListener("click", async () => { + const name = $("#g-name").value.trim(); + if (!name) return alert("A name is all it needs 🙂"); + const pick = (g) => view.querySelector(`.chip[data-group="${g}"].on`)?.dataset.val || ""; + const materials = [...mats.querySelectorAll(".matrow")].map((r) => { + const [n, p] = r.querySelectorAll("input"); + return n.value.trim() && p.value.trim() ? { name: n.value.trim(), pct: parseFloat(p.value), addition: n.dataset.add === "1" } : null; + }).filter(Boolean); + await api("/api/glazes", { name, kind: pick("kind"), cone: pick("cone"), atmosphere: pick("atmosphere"), surface: pick("surface"), source: pick("source"), swatch: pick("swatch"), materials }); + glazes(); + }); +} + +async function glazeDetail(id) { + const g = await api(`/api/glaze?id=${id}`); + const batchSizes = [500, 1000, 2000, 5000]; + const batch = (pct, size) => Math.round(pct * size) / 100; + let size = 2000; + const recipeTable = () => ` + + ${g.materials.map((m) => ``).join("") || ''}
Material%${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch
${m.addition ? "+ " : ""}${esc(m.name)}${m.pct}${batch(m.pct, size)} g
no recipe yet
`; + const compat = g.compatibility.length + ? ` + ${g.compatibility.map((c) => ``).join("")}
Clay bodyResultEvidence
${esc(c.clay)}${esc(c.rating)}${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""}
` + : `
no evidence yet — it builds itself from unload days 🌱
`; + view.innerHTML = ` +
+
+

${esc(g.name)}

+
${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}
+

Recipe

${recipeTable()}
+
${batchSizes.map((b) => `${b >= 1000 ? b / 1000 + " kg" : b + " g"}`).join("")}
+

Plays well with…

${compat}
+

Journal

+ ${g.journal.map((j) => `
📒 ${esc(j.created.slice(0, 10))} — ${esc(j.text)}
`).join("") || '
no entries yet
'} +
+

Photos

+ ${g.photos.map((p) => ``).join("")} + +
+
`; + $("#back").addEventListener("click", glazes); + view.querySelectorAll("[data-b]").forEach((c) => + c.addEventListener("click", () => { size = Number(c.dataset.b); $("#recipe").innerHTML = recipeTable(); + view.querySelectorAll("[data-b]").forEach((x) => x.classList.toggle("on", x === c)); })); + $("#j-save").addEventListener("click", async () => { + const text = $("#j-text").value.trim(); + if (text) { await api("/api/glaze/journal", { glaze_id: id, text }); glazeDetail(id); } + }); + $("#ph-add").addEventListener("click", () => $("#ph-file").click()); + $("#ph-file").addEventListener("change", async (e) => { + const f = e.target.files[0]; + if (!f) return; + await fetch(`/api/photos?entity=glaze&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); + glazeDetail(id); + }); +} + +// ── Firing Log ─────────────────────────────────────────────────── +async function firings() { + const list = await api("/api/firings"); + const library = await api("/api/glazes"); + const glazeNames = library.filter((g) => g.kind !== "clay body").map((g) => g.name); + const clayNames = library.filter((g) => g.kind === "clay body").map((g) => g.name); + const active = list.find((f) => f.status === "firing"); + let html = ""; + if (!active) { + html += `

Start a firing 🔥

+
Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.
+
${["bisque", "glaze"].map((t, i) => `${t}`).join("")} + ${["∆04", "∆6", "∆10"].map((c, i) => `${c}`).join("")}
+
`; + } else { + const items = active.items.map((it) => `
${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}
`).join(""); + html += `

Firing #${active.id} — in progress 🔥

+
${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}
+
${items || '
no load list — totally fine! add pieces whenever, even at unload
'}
+
+
+ ${clayNames.map((n) => ``).join("")} + +
+
`; + } + html += list.filter((f) => f.status === "unloaded").map((f) => ` +

Firing #${f.id} — ${esc(f.type)} ${esc(f.cone)}

+
${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}
+ ${f.items.map((it) => `
${it.rating || ""} ${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}
`).join("")} + ${f.result_notes ? `
✨ ${esc(f.result_notes)}
` : ""}
`).join(""); + view.innerHTML = html || '
no firings yet
'; + view.querySelectorAll("[data-ft],[data-fc]").forEach((c) => + c.addEventListener("click", () => { + const sel = c.dataset.ft !== undefined ? "[data-ft]" : "[data-fc]"; + view.querySelectorAll(sel).forEach((x) => x.classList.toggle("on", x === c)); + })); + $("#start-firing")?.addEventListener("click", async () => { + await api("/api/firings", { type: view.querySelector("[data-ft].on").dataset.ft, cone: view.querySelector("[data-fc].on").dataset.fc }); + firings(); + }); + $("#fi-add")?.addEventListener("click", async () => { + const piece = $("#fi-piece").value.trim(); + if (!piece) return; + await api("/api/firing/items", { firing_id: active.id, piece, clay: $("#fi-clay").value.trim(), glaze: $("#fi-glaze").value }); + firings(); + }); + $("#unload")?.addEventListener("click", () => unloadDay(active)); +} + +async function unloadDay(f) { + const fresh = (await api("/api/firings")).find((x) => x.id === f.id); + const EMOJI = ["😍", "🙂", "😕", "💔"]; + view.innerHTML = ` +

Firing #${fresh.id} — unload day! ✨

+
Rate each thing as it comes out. One tap each.
+ ${fresh.items.map((it) => `
${esc(it.piece)} ${esc([it.clay, it.glaze].filter(Boolean).join(" · "))} + ${EMOJI.map((e) => ``).join("")}
`).join("") || + '
no items listed — add what came out below, then rate it
'} +
+
+
`; + $("#back").addEventListener("click", firings); + view.querySelectorAll(".verdict").forEach((v) => + v.querySelectorAll("button").forEach((b) => + b.addEventListener("click", async () => { + await api("/api/firing/rate", { id: Number(v.dataset.item), rating: b.dataset.e }); + v.querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b)); + }))); + $("#fi-add").addEventListener("click", async () => { + const piece = $("#fi-piece").value.trim(); + if (piece) { await api("/api/firing/items", { firing_id: fresh.id, piece }); unloadDay(fresh); } + }); + $("#save-unload").addEventListener("click", async () => { + await api("/api/firing/unload", { id: fresh.id, result_notes: $("#f-notes").value.trim() }); + firings(); + }); +} + +// ── Pieces ─────────────────────────────────────────────────────── +const money = (n) => "$" + (Math.round(n * 100) / 100).toLocaleString(); +async function pieces() { + const { products, money: m } = await api("/api/products"); + const core = products.filter((p) => p.kind === "core line"); + const tiles = products.filter((p) => p.kind === "tiles"); + const oneoff = products.filter((p) => p.kind !== "core line" && p.kind !== "tiles"); + const catPill = (p) => (p.category ? `${esc(p.category)}` : ""); + const coreCard = (p) => ` +

${esc(p.name)}

+
${catPill(p)} ${money(p.price)}
+
on hand: ${p.qty} + + +
`; + const STATUSES = ["in progress", "for sale", "sold"]; + const oneoffCard = (p) => ` +

${esc(p.name)}

+
${catPill(p)} ${esc(p.kind)} ${money(p.price)}
+
${STATUSES.map((s) => `${s}`).join("")} + ${p.status !== "sold" ? `` : ""}
`; + const section = (label) => `

${label}

`; + view.innerHTML = ` +

Money 💰

+
+
this month

${money(m.monthRevenue)}

+
all-time sales (${m.salesCount})

${money(m.revenue)}

+
est. profit

${money(m.revenue - m.cogs)}

+
+
profit = sales minus each piece's cost estimate · real materials-based costing comes later
+
+ ${section("Core line ✨")} +
${core.map(coreCard).join("") || '
the reintroduced line starts here — kitchen tools, serveware…
'}
+ ${tiles.length ? section("Tiles 🔷") + `
${tiles.map(coreCard).join("")}
` : ""} + ${section("Small batch & one of a kind 🌙")} +
${oneoff.map(oneoffCard).join("") || '
sculptural pieces, home scenting, experiments…
'}
`; + $("#new-piece").addEventListener("click", newPieceForm); + view.querySelectorAll("[data-open]").forEach((h) => + h.addEventListener("click", () => pieceDetail(Number(h.dataset.open)))); + view.querySelectorAll("[data-q]").forEach((b) => + b.addEventListener("click", async () => { await api("/api/products/qty", { id: Number(b.dataset.q), delta: Number(b.dataset.d) }); pieces(); })); + view.querySelectorAll("[data-st]").forEach((c) => + c.addEventListener("click", async () => { await api("/api/products/status", { id: Number(c.dataset.st), status: c.dataset.v }); pieces(); })); + view.querySelectorAll("[data-sell]").forEach((b) => + b.addEventListener("click", async () => { await api("/api/sales", { product_id: Number(b.dataset.sell) }); pieces(); })); +} + +function newPieceForm() { + const chip = (group, vals, on) => vals.map((v) => `${v}`).join(""); + view.innerHTML = ` +

New piece

+
Name is enough — price, cost, and count can arrive later.
+
+
+
${chip("kind", ["core line", "tiles", "small batch", "one of a kind"], "core line")}
+
+
+
+
+
+
+
`; + view.querySelectorAll(".chip[data-group]").forEach((c) => + c.addEventListener("click", () => view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c)))); + $("#back").addEventListener("click", pieces); + $("#save-piece").addEventListener("click", async () => { + const name = $("#p-name").value.trim(); + if (!name) return alert("Just a name is fine 🙂"); + await api("/api/products", { + name, kind: view.querySelector('.chip[data-group="kind"].on')?.dataset.val, + category: $("#p-cat").value.trim(), price: $("#p-price").value, cost: $("#p-cost").value, qty: $("#p-qty").value }); + pieces(); + }); +} + +// ── Files ──────────────────────────────────────────────────────── +const FILE_TAGS = ["master molds", "foam forms", "prototypes", "other"]; +const fmtSize = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB"; +const fileIcon = (name) => /\.(stl|obj|3mf|step|stp)$/i.test(name) ? "🧊" : /\.(svg|dxf|ai|eps)$/i.test(name) ? "✂️" : /\.(png|jpe?g|webp|heic)$/i.test(name) ? "🖼️" : /\.(pdf)$/i.test(name) ? "📄" : "📁"; +function fileRow(f, showHome) { + return `
${fileIcon(f.name)} ${esc(f.name)} + ${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))} + ${f.tag ? `${esc(f.tag)}` : ""} + ${showHome && f.product_name ? `🏺 ${esc(f.product_name)}` : ""} +
`; +} +function uploadWidget(entity, entityId, after) { + return { + html: `
+ +
+
tag as…${FILE_TAGS.map((t, i) => `${t}`).join("")}
`, + wire() { + view.querySelectorAll("[data-ftag]").forEach((c) => + c.addEventListener("click", () => view.querySelectorAll("[data-ftag]").forEach((x) => x.classList.toggle("on", x === c)))); + $("#file-pick").addEventListener("click", () => $("#file-input").click()); + $("#file-input").addEventListener("change", async (e) => { + const tag = view.querySelector("[data-ftag].on")?.dataset.ftag || ""; + for (const f of e.target.files) { + const q = new URLSearchParams({ name: f.name, tag, ...(entity ? { entity, id: entityId } : {}) }); + await fetch(`/api/files?${q}`, { method: "POST", body: f }); + } + after(); + }); + }, + }; +} +function wireFileDeletes(after) { + view.querySelectorAll("[data-fdel]").forEach((b) => + b.addEventListener("click", async () => { + if (confirm("Delete this file?")) { await api("/api/files/delete", { id: Number(b.dataset.fdel) }); after(); } + })); +} +async function files() { + const list = await api("/api/files"); + const up = uploadWidget("", 0, files); + const loose = list.filter((f) => !f.entity_id); + const attached = list.filter((f) => f.entity_id); + const section = (label) => `

${label}

`; + view.innerHTML = `${up.html} +
${section("Studio files 🗂️")} + ${loose.map((f) => fileRow(f, false)).join("") || '
STLs for master molds, foam form templates, cut files… upload anything ⬆️
'}
+ ${attached.length ? `
${section("Attached to pieces 🏺")}${attached.map((f) => fileRow(f, true)).join("")}
` : ""}`; + up.wire(); + wireFileDeletes(files); +} + +// ── Piece detail ───────────────────────────────────────────────── +async function pieceDetail(id) { + const p = await api(`/api/product?id=${id}`); + if (!p) return pieces(); + const counted = p.kind === "core line" || p.kind === "tiles"; + const STATUSES = ["in progress", "for sale", "sold"]; + const up = uploadWidget("product", id, () => pieceDetail(id)); + view.innerHTML = ` +
+

${esc(p.name)}

+
${p.category ? `${esc(p.category)}` : ""} + ${esc(p.kind)} ${money(p.price)} + ${p.cost ? `cost ~${money(p.cost)}` : ""}
+
${counted + ? `on hand: ${p.qty} + + ` + : `${STATUSES.map((s) => `${s}`).join("")} + ${p.status !== "sold" ? `` : ""}`}
+ ${p.sales.length ? `
💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}
` : ""} +
+ +
+

Photos

+ ${p.photos.map((ph) => ``).join("")} +
+

Files — molds, templates, cut forms

+ ${p.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'} + ${up.html}
+
`; + $("#back").addEventListener("click", pieces); + view.querySelectorAll("[data-q]").forEach((b) => + b.addEventListener("click", async () => { await api("/api/products/qty", { id, delta: Number(b.dataset.q) }); pieceDetail(id); })); + view.querySelectorAll("[data-st]").forEach((c) => + c.addEventListener("click", async () => { await api("/api/products/status", { id, status: c.dataset.st }); pieceDetail(id); })); + $("#sell")?.addEventListener("click", async () => { await api("/api/sales", { product_id: id }); pieceDetail(id); }); + $("#save-notes").addEventListener("click", async () => { await api("/api/products/notes", { id, notes: $("#p-notes").value }); }); + $("#ph-add").addEventListener("click", () => $("#ph-file").click()); + $("#ph-file").addEventListener("change", async (e) => { + const f = e.target.files[0]; + if (f) { await fetch(`/api/photos?entity=product&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); pieceDetail(id); } + }); + up.wire(); + wireFileDeletes(() => pieceDetail(id)); +} + +// ── Sourcing ───────────────────────────────────────────────────── +async function sourcing() { + const { suppliers, entries } = await api("/api/sourcing"); + const section = (label) => `

${label}

`; + const total = (e) => e.price + e.shipping; + // group price entries by item (case-insensitive) + const items = {}; + for (const e of entries) (items[e.item.toLowerCase()] ??= { name: e.item, entries: [] }).entries.push(e); + const itemCard = (it) => { + // newest entry per supplier decides "current" prices; lowest total wins + const latestBySupplier = {}; + for (const e of it.entries) latestBySupplier[e.supplier_id] ??= e; // entries are newest-first + const currents = Object.values(latestBySupplier).sort((a, b) => total(a) - total(b)); + const bestId = currents[0]?.id; + const rows = it.entries.map((e) => { + // trend vs previous entry from the same supplier + const prev = it.entries.find((x) => x.supplier_id === e.supplier_id && x.id < e.id); + const trend = prev ? (total(e) > total(prev) ? ` ▲ costs more now` + : total(e) < total(prev) ? ` ▼ cheaper` : "") : ""; + const drivable = e.supplier_kind === "local" ? ` 🚗 ${esc(e.distance || "drivable")}` : ""; + return `
${esc(e.supplier)}${drivable} ${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""} + ${money(e.price)}${e.shipping ? ` + ${money(e.shipping)} ship = ${money(total(e))}` : ""}${trend} + ${e.id === bestId && currents.length > 1 ? ' best 🏆' : ""}
`; + }).join(""); + return `

${esc(it.name)}

${rows}
`; + }; + const supplierCard = (s) => ` +

${esc(s.name)}

+
${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"} + ${s.url ? ` visit site ↗` : ""}
+ ${s.notes ? `
${esc(s.notes)}
` : ""}
`; + const searchable = suppliers.filter((s) => s.search_url); + view.innerHTML = ` + ${searchable.length ? `
+ +
+
` : ""} +
+ + + + +
+
log every buy or price-check — the comparisons build themselves 🌱
+ ${Object.values(items).map(itemCard).join("") || '
no prices logged yet — start with your next order 🧭
'} + ${section("Suppliers")} +
${suppliers.map(supplierCard).join("") || '
MN Clay, the online places…
'}
+

+ New supplier

+
+
+
+ 📦 online🚗 can drive there
+
+
+
+
+
+
+
`; + view.querySelectorAll("[data-skind]").forEach((c) => + c.addEventListener("click", () => view.querySelectorAll("[data-skind]").forEach((x) => x.classList.toggle("on", x === c)))); + $("#s-save").addEventListener("click", async () => { + const name = $("#s-name").value.trim(); + if (!name) return alert("Name is all it needs 🙂"); + await api("/api/suppliers", { name, kind: view.querySelector("[data-skind].on").dataset.skind, + distance: $("#s-dist").value.trim(), url: $("#s-url").value.trim(), notes: $("#s-notes").value.trim(), + search_url: $("#s-search").value.trim() }); + sourcing(); + }); + const priceCheck = () => { + const q = $("#pc-q")?.value.trim(); + if (!q) return; + $("#pc-links").innerHTML = searchable.map((s) => + `🔎 ${esc(s.name)} ↗`).join(""); + }; + $("#pc-go")?.addEventListener("click", priceCheck); + $("#pc-q")?.addEventListener("keydown", (e) => e.key === "Enter" && priceCheck()); + const logPrice = async () => { + const item = $("#pe-item").value.trim(), sup = $("#pe-supplier").value; + if (!item || !sup) return; + await api("/api/price-entries", { item, supplier_id: Number(sup), price: $("#pe-price").value, shipping: $("#pe-ship").value }); + sourcing(); + }; + $("#pe-save").addEventListener("click", logPrice); + ["pe-item", "pe-price", "pe-ship"].forEach((id) => + $("#" + id).addEventListener("keydown", (e) => e.key === "Enter" && logPrice())); +} + +// ── Notes ──────────────────────────────────────────────────────── +async function notes() { + const list = await api("/api/notes"); + view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"]]) + + `
${list.map((n) => ` +

${esc(n.text)}

+
${esc(n.created.slice(0, 16))} + ${n.tags ? `${esc(n.tags)}` : ""}
`).join("") || + '
no notes yet — the jot box awaits ✨
'}
`; + wireCapture(notes); +} + +document.getElementById("signout")?.addEventListener("click", async () => { + await api("/api/logout", {}); + location.href = "/login"; +}); + +render(); diff --git a/app/public/index.html b/app/public/index.html new file mode 100644 index 0000000..75b4552 --- /dev/null +++ b/app/public/index.html @@ -0,0 +1,110 @@ + + + + + +🌙 Studio Notebook + + + +
+ +
+
+
welcome back
+
+
+ +
+
+ + + diff --git a/app/public/login.html b/app/public/login.html new file mode 100644 index 0000000..02b05c6 --- /dev/null +++ b/app/public/login.html @@ -0,0 +1,47 @@ + + + + + +🌙 Studio Notebook — sign in + + + +
+

🌙 Studio Notebook

+
welcome back to the studio
+
+ + + +
+
+
+ + + diff --git a/app/scripts/migrate-sqlite.mjs b/app/scripts/migrate-sqlite.mjs new file mode 100644 index 0000000..e96d467 --- /dev/null +++ b/app/scripts/migrate-sqlite.mjs @@ -0,0 +1,65 @@ +// One-shot: copy Bonna's laptop prototype data (SQLite) into the hosted +// Postgres. Run from app/ with DATABASE_URL set: +// node scripts/migrate-sqlite.mjs /path/to/prototype/data/studio.db +// Idempotent-ish: skips any table that already has rows in Postgres. +import { DatabaseSync } from "node:sqlite"; +import { PrismaClient } from "@prisma/client"; + +const src = process.argv[2]; +if (!src) { console.error("usage: node scripts/migrate-sqlite.mjs "); process.exit(1); } +const lite = new DatabaseSync(src); +const db = new PrismaClient(); +const rows = (sql) => { try { return lite.prepare(sql).all(); } catch { return []; } }; +const d = (s) => (s ? new Date(s.replace(" ", "T")) : new Date()); + +async function copy(name, model, sqliteSql, map) { + if ((await model.count()) > 0) return console.log(`${name}: postgres has rows — skipped`); + const data = rows(sqliteSql); + for (const r of data) await model.create({ data: map(r) }); + console.log(`${name}: ${data.length} migrated`); +} + +// order matters (children reference parents; ids preserved by inserting in id order) +await copy("suppliers", db.supplier, "SELECT * FROM suppliers ORDER BY id", (r) => ({ + id: r.id, name: r.name, kind: r.kind, distance: r.distance, url: r.url, notes: r.notes, + searchUrl: r.search_url || "", created: d(r.created) })); +await copy("price_entries", db.priceEntry, "SELECT * FROM price_entries ORDER BY id", (r) => ({ + id: r.id, item: r.item, supplierId: r.supplier_id, price: r.price, unit: r.unit, + shipping: r.shipping, note: r.note, created: d(r.created) })); +await copy("glazes", db.glaze, "SELECT * FROM glazes ORDER BY id", (r) => ({ + id: r.id, name: r.name, kind: r.kind, cone: r.cone, atmosphere: r.atmosphere, surface: r.surface, + source: r.source, sg: r.sg, swatch: r.swatch, created: d(r.created) })); +await copy("glaze_materials", db.glazeMaterial, "SELECT * FROM glaze_materials ORDER BY id", (r) => ({ + id: r.id, glazeId: r.glaze_id, name: r.name, pct: r.pct, addition: !!r.addition })); +await copy("glaze_journal", db.glazeJournal, "SELECT * FROM glaze_journal ORDER BY id", (r) => ({ + id: r.id, glazeId: r.glaze_id, text: r.text, created: d(r.created) })); +await copy("firings", db.firing, "SELECT * FROM firings ORDER BY id", (r) => ({ + id: r.id, type: r.type, cone: r.cone, schedule: r.schedule, status: r.status, + resultNotes: r.result_notes, started: d(r.started), unloaded: r.unloaded ? d(r.unloaded) : null })); +await copy("firing_items", db.firingItem, "SELECT * FROM firing_items ORDER BY id", (r) => ({ + id: r.id, firingId: r.firing_id, piece: r.piece, clay: r.clay, glaze: r.glaze, rating: r.rating, note: r.note })); +await copy("products", db.product, "SELECT * FROM products ORDER BY id", (r) => ({ + id: r.id, name: r.name, kind: r.kind, category: r.category, price: r.price, cost: r.cost, + qty: r.qty, status: r.status, notes: r.notes || "", created: d(r.created) })); +await copy("sales", db.sale, "SELECT * FROM sales ORDER BY id", (r) => ({ + id: r.id, productId: r.product_id, qty: r.qty, price: r.price, cost: r.cost, created: d(r.created) })); +await copy("todos", db.todo, "SELECT * FROM todos ORDER BY id", (r) => ({ + id: r.id, text: r.text, done: !!r.done, parentId: r.parent_id, created: d(r.created) })); +await copy("shopping", db.shopping, "SELECT * FROM shopping ORDER BY id", (r) => ({ + id: r.id, text: r.text, done: !!r.done, created: d(r.created) })); +await copy("notes", db.note, "SELECT * FROM notes ORDER BY id", (r) => ({ + id: r.id, text: r.text, tags: r.tags, created: d(r.created) })); +await copy("photos", db.photo, "SELECT * FROM photos ORDER BY id", (r) => ({ + id: r.id, entity: r.entity, entityId: r.entity_id, filename: r.filename, created: d(r.created) })); +await copy("files", db.fileAsset, "SELECT * FROM files ORDER BY id", (r) => ({ + id: r.id, entity: r.entity, entityId: r.entity_id, stored: r.stored, name: r.name, + tag: r.tag, size: r.size, created: d(r.created) })); + +// bump Postgres sequences past the migrated ids +for (const t of ["User", "Note", "Todo", "Shopping", "Glaze", "GlazeMaterial", "GlazeJournal", + "Firing", "FiringItem", "Photo", "FileAsset", "Product", "Sale", "Supplier", "PriceEntry"]) { + await db.$executeRawUnsafe( + `SELECT setval(pg_get_serial_sequence('"${t}"','id'), COALESCE((SELECT MAX(id) FROM "${t}"), 0) + 1, false)`); +} +console.log("sequences bumped — done 🌙 (photos/files binaries: copy prototype/data/{photos,files} into the container's /app/data)"); +await db.$disconnect(); diff --git a/app/scripts/seed-owner.mjs b/app/scripts/seed-owner.mjs new file mode 100644 index 0000000..830b0e9 --- /dev/null +++ b/app/scripts/seed-owner.mjs @@ -0,0 +1,18 @@ +// OTM owner seed — runs at container start. Creates the owner ONLY when +// SEED_OWNER_EMAIL/SEED_OWNER_PASSWORD_HASH are set AND the User table is +// empty (the platform convention — see platform CLAUDE.md "Owner seed"). +import { PrismaClient } from "@prisma/client"; + +const db = new PrismaClient(); +const email = process.env.SEED_OWNER_EMAIL?.toLowerCase().trim(); +const hash = process.env.SEED_OWNER_PASSWORD_HASH; + +if (!email || !hash) { + console.log("seed-owner: SEED_OWNER_* not set — skipping"); +} else if ((await db.user.count()) > 0) { + console.log("seed-owner: users exist — skipping"); +} else { + await db.user.create({ data: { email, passwordHash: hash, role: "OWNER" } }); + console.log(`seed-owner: created OWNER ${email}`); +} +await db.$disconnect(); diff --git a/app/server.mjs b/app/server.mjs new file mode 100644 index 0000000..a055cfc Binary files /dev/null and b/app/server.mjs differ