app/: production port — Postgres + Prisma + OTM-account auth (v0.2.0)
Prototype stays untouched (Bonna's laptop daily driver until cutover). app/ is the deployable bms-backbone: same routes/frontend, Prisma models replacing node:sqlite, session-cookie login backed by a User table seeded via the platform SEED_OWNER_* convention (bcryptjs), /api/health with APP_VERSION, photos/files under DATA_DIR (/app/data uploads mount), Dockerfile + entrypoint (prisma db push + owner seed + serve), and a one-shot sqlite→postgres migration script for Bonna's existing data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
15
app/Dockerfile
Normal file
15
app/Dockerfile
Normal file
@@ -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"]
|
||||
8
app/docker-entrypoint.sh
Executable file
8
app/docker-entrypoint.sh
Executable file
@@ -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
|
||||
1
app/lib/version.mjs
Normal file
1
app/lib/version.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.2.0";
|
||||
451
app/package-lock.json
generated
Normal file
451
app/package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/package.json
Normal file
17
app/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
160
app/prisma/schema.prisma
Normal file
160
app/prisma/schema.prisma
Normal file
@@ -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())
|
||||
}
|
||||
618
app/public/app.js
Normal file
618
app/public/app.js
Normal file
@@ -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) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
|
||||
return `<div class="capture"><div class="row1">
|
||||
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea>
|
||||
<button class="go" id="jot-save">Save</button></div>
|
||||
<div class="chips"><span class="hint">file it as…</span>${chips}</div>
|
||||
<div class="chips"><span class="hint">⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot</span></div></div>`;
|
||||
}
|
||||
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 `<div class="todo" ${sub ? 'style="margin-left:26px"' : ""}>
|
||||
<input type="checkbox" data-t="${t.id}" ${t.done ? "checked" : ""}>
|
||||
<span class="${t.done ? "done" : ""}" style="flex:1">${sub ? "↳ " : ""}${esc(t.text)}</span>
|
||||
${sub ? "" : `<button class="chip" data-sub="${t.id}" title="add subtask">+</button>`}</div>
|
||||
${sub ? "" : `<div class="subform" data-subform="${t.id}" hidden style="margin:2px 0 6px 26px">
|
||||
<input type="text" placeholder="subtask… ⇧Enter = another · Enter = new task · ⌫ empty = undo · Esc" style="width:88%;padding:7px 12px;font-size:13.5px"></div>`}`;
|
||||
}
|
||||
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)) || `<div class="empty">nothing yet — jot one above ✨</div>`;
|
||||
const shopRows = s.shopping.map((t) =>
|
||||
`<div class="todo"><input type="checkbox" data-s="${t.id}" ${t.done ? "checked" : ""}><span class="${t.done ? "done" : ""}">${esc(t.text)}</span></div>`).join("") || `<div class="empty">list is empty — lucky you 🧺</div>`;
|
||||
const noteRows = s.notes.map((n) => `<div class="todo"><span>“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”</span></div>`).join("") || `<div class="empty">no notes yet</div>`;
|
||||
const kiln = s.activeFiring
|
||||
? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div>
|
||||
<div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>`
|
||||
: `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`;
|
||||
view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"]]) +
|
||||
`<div class="grid g2">
|
||||
<div class="card"><h3>To-do</h3>${todoRows}</div>
|
||||
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
|
||||
<div class="card"><h3>Kiln</h3>${kiln}</div>
|
||||
<div class="card"><h3>Recent notes</h3>${noteRows}</div>
|
||||
</div>`;
|
||||
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) => `
|
||||
<div class="card clickable" data-g="${g.id}">
|
||||
<div class="swatch" style="background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
|
||||
<h4>${esc(g.name)}</h4>
|
||||
<div class="meta">${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}</div>
|
||||
</div>`;
|
||||
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
|
||||
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 = `
|
||||
<div class="mt" style="margin-bottom:14px"><button class="btn" id="new-glaze">+ New glaze, slip, or clay body</button></div>
|
||||
<div class="grid g3">${glazeCards || '<div class="empty">no glazes yet — add your first 🧪</div>'}</div>
|
||||
${slipCards ? section("Casting slips 🏺") + `<div class="grid g3">${slipCards}</div>` : ""}
|
||||
${clayCards ? section("Clay bodies 🧱") + `<div class="grid g3">${clayCards}</div>` : ""}`;
|
||||
$("#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) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
|
||||
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
|
||||
<div class="card">
|
||||
<h4>New glaze</h4>
|
||||
<div class="meta" style="margin-bottom:14px">Only the name is required — everything else can arrive later.</div>
|
||||
<div class="field"><label>Name</label><input type="text" id="g-name" placeholder="e.g. Moonrise Blue" style="width:100%"></div>
|
||||
<div class="grid g2">
|
||||
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}</div></div>
|
||||
<div class="field"><label>Cone</label><div class="chips">${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}</div></div>
|
||||
<div class="field"><label>Atmosphere</label><div class="chips">${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}</div></div>
|
||||
<div class="field"><label>Surface</label><div class="chips">${chip("surface", ["glossy", "satin", "matte"], "")}</div></div>
|
||||
<div class="field"><label>Where's it from?</label><div class="chips">${chip("source", ["mine", "Glazy", "book / friend"], "mine")}</div></div>
|
||||
<div class="field"><label>Swatch color</label><div class="chips">${SWATCHES.map((s, i) => `<span class="chip${i === 0 ? " on" : ""}" data-group="swatch" data-val="${s}" style="background:${s};color:#fff">●</span>`).join("")}</div></div>
|
||||
</div>
|
||||
<div class="field"><label>Recipe (material + %)</label><div id="mats"></div>
|
||||
<button class="btn soft mini" id="add-mat">+ material</button>
|
||||
<button class="btn soft mini" id="add-addition">+ addition (colorant etc.)</button></div>
|
||||
<button class="btn" id="save-glaze">Save glaze</button>
|
||||
</div>`;
|
||||
const mats = $("#mats");
|
||||
const addRow = (addition) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "matrow";
|
||||
row.innerHTML = `<input type="text" placeholder="${addition ? "+ e.g. Copper Carbonate" : "e.g. Custer Feldspar"}" data-add="${addition ? 1 : 0}"><input type="text" placeholder="%" inputmode="decimal">`;
|
||||
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 = () => `
|
||||
<table><tr><th>Material</th><th>%</th><th>${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch</th></tr>
|
||||
${g.materials.map((m) => `<tr><td>${m.addition ? "+ " : ""}${esc(m.name)}</td><td>${m.pct}</td><td>${batch(m.pct, size)} g</td></tr>`).join("") || '<tr><td colspan="3" class="empty">no recipe yet</td></tr>'}</table>`;
|
||||
const compat = g.compatibility.length
|
||||
? `<table><tr><th>Clay body</th><th>Result</th><th>Evidence</th></tr>
|
||||
${g.compatibility.map((c) => `<tr><td>${esc(c.clay)}</td><td>${esc(c.rating)}</td><td>${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""}</td></tr>`).join("")}</table>`
|
||||
: `<div class="empty">no evidence yet — it builds itself from unload days 🌱</div>`;
|
||||
view.innerHTML = `<button class="back" id="back">← back to glazes</button>
|
||||
<div class="card">
|
||||
<div class="swatch" style="max-width:140px;background:linear-gradient(150deg,${g.swatch || "#cfc8ba"},${g.swatch || "#cfc8ba"}cc)"></div>
|
||||
<h4>${esc(g.name)}</h4>
|
||||
<div class="meta">${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}</div>
|
||||
<div class="mt"><h3>Recipe</h3><div id="recipe">${recipeTable()}</div>
|
||||
<div class="chips">${batchSizes.map((b) => `<span class="chip${b === size ? " on" : ""}" data-b="${b}">${b >= 1000 ? b / 1000 + " kg" : b + " g"}</span>`).join("")}</div></div>
|
||||
<div class="mt"><h3>Plays well with…</h3>${compat}</div>
|
||||
<div class="mt"><h3>Journal</h3>
|
||||
${g.journal.map((j) => `<div class="result">📒 <b>${esc(j.created.slice(0, 10))}</b> — ${esc(j.text)}</div>`).join("") || '<div class="empty">no entries yet</div>'}
|
||||
<div class="capture mt"><div class="row1"><input type="text" id="j-text" placeholder="New journal entry…"><button class="go" id="j-save">Add</button></div></div></div>
|
||||
<div class="mt"><h3>Photos</h3><div class="photos" id="photos">
|
||||
${g.photos.map((p) => `<img src="/photos/${p.filename}" alt="">`).join("")}
|
||||
<button class="ph-add" id="ph-add">+</button>
|
||||
<input type="file" id="ph-file" accept="image/*" hidden></div></div>
|
||||
</div>`;
|
||||
$("#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 += `<div class="card" style="margin-bottom:14px"><h3>Start a firing 🔥</h3>
|
||||
<div class="meta" style="margin-bottom:10px">Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.</div>
|
||||
<div class="chips">${["bisque", "glaze"].map((t, i) => `<span class="chip${i === 1 ? " on" : ""}" data-ft="${t}">${t}</span>`).join("")}
|
||||
${["∆04", "∆6", "∆10"].map((c, i) => `<span class="chip${i === 1 ? " on" : ""}" data-fc="${c}">${c}</span>`).join("")}</div>
|
||||
<div class="mt"><button class="btn" id="start-firing">Kiln is loaded — start 🔥</button></div></div>`;
|
||||
} else {
|
||||
const items = active.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></div>`).join("");
|
||||
html += `<div class="card" style="margin-bottom:14px"><h3>Firing #${active.id} — in progress 🔥</h3>
|
||||
<div class="meta">${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}</div>
|
||||
<div class="mt">${items || '<div class="empty">no load list — totally fine! add pieces whenever, even at unload</div>'}</div>
|
||||
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="piece, e.g. 6 carved mugs"></div>
|
||||
<div class="row1 mt"><input type="text" id="fi-clay" list="clay-list" placeholder="clay (optional)" style="flex:1">
|
||||
<datalist id="clay-list">${clayNames.map((n) => `<option>${esc(n)}</option>`).join("")}</datalist>
|
||||
<select id="fi-glaze"><option value="">glaze (optional)</option>${glazeNames.map((n) => `<option>${esc(n)}</option>`).join("")}</select>
|
||||
<button class="go" id="fi-add">Add</button></div></div>
|
||||
<div class="mt"><button class="btn" id="unload">Unload day ✨</button></div></div>`;
|
||||
}
|
||||
html += list.filter((f) => f.status === "unloaded").map((f) => `
|
||||
<div class="card" style="margin-bottom:14px"><h4>Firing #${f.id} — ${esc(f.type)} ${esc(f.cone)}</h4>
|
||||
<div class="meta">${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}</div>
|
||||
${f.items.map((it) => `<div class="loadrow"><span>${it.rating || ""} ${esc(it.piece)}</span><span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}</span></div>`).join("")}
|
||||
${f.result_notes ? `<div class="result">✨ ${esc(f.result_notes)}</div>` : ""}</div>`).join("");
|
||||
view.innerHTML = html || '<div class="empty">no firings yet</div>';
|
||||
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 = `<button class="back" id="back">← firing log</button>
|
||||
<div class="card"><h4>Firing #${fresh.id} — unload day! ✨</h4>
|
||||
<div class="meta" style="margin-bottom:10px">Rate each thing as it comes out. One tap each.</div>
|
||||
${fresh.items.map((it) => `<div class="loadrow"><span>${esc(it.piece)} <span class="meta">${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}</span></span>
|
||||
<span class="verdict" data-item="${it.id}">${EMOJI.map((e) => `<button data-e="${e}" class="${it.rating === e ? "on" : ""}">${e}</button>`).join("")}</span></div>`).join("") ||
|
||||
'<div class="empty">no items listed — add what came out below, then rate it</div>'}
|
||||
<div class="capture mt"><div class="row1"><input type="text" id="fi-piece" placeholder="add something that came out…"><button class="go" id="fi-add">Add</button></div></div>
|
||||
<div class="field mt"><label>Notes on this firing</label><textarea id="f-notes" rows="2" style="width:100%" placeholder="anything future-you should know"></textarea></div>
|
||||
<button class="btn" id="save-unload">Save unload ✨</button></div>`;
|
||||
$("#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 ? `<span class="pill butter">${esc(p.category)}</span>` : "");
|
||||
const coreCard = (p) => `
|
||||
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta">›</span></h4>
|
||||
<div class="mt">${catPill(p)} <span class="pill">${money(p.price)}</span></div>
|
||||
<div class="loadrow mt"><span>on hand: <b>${p.qty}</b></span>
|
||||
<span><button class="chip" data-q="${p.id}" data-d="-1">−</button>
|
||||
<button class="chip" data-q="${p.id}" data-d="1">+</button>
|
||||
<button class="btn mini" data-sell="${p.id}" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span></div></div>`;
|
||||
const STATUSES = ["in progress", "for sale", "sold"];
|
||||
const oneoffCard = (p) => `
|
||||
<div class="card"><h4 class="clickable" data-open="${p.id}" title="open piece page">${esc(p.name)} <span class="meta">›</span></h4>
|
||||
<div class="mt">${catPill(p)} <span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span></div>
|
||||
<div class="loadrow mt"><span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${p.id}" data-v="${s}">${s}</span>`).join("")}</span>
|
||||
${p.status !== "sold" ? `<button class="btn mini" data-sell="${p.id}">sold 💰</button>` : ""}</div></div>`;
|
||||
const section = (label) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
|
||||
view.innerHTML = `
|
||||
<div class="card" style="margin-bottom:14px"><h3>Money 💰</h3>
|
||||
<div class="grid g3">
|
||||
<div><div class="meta">this month</div><h4>${money(m.monthRevenue)}</h4></div>
|
||||
<div><div class="meta">all-time sales (${m.salesCount})</div><h4>${money(m.revenue)}</h4></div>
|
||||
<div><div class="meta">est. profit</div><h4>${money(m.revenue - m.cogs)}</h4></div>
|
||||
</div>
|
||||
<div class="meta mt">profit = sales minus each piece's cost estimate · real materials-based costing comes later</div></div>
|
||||
<div style="margin-bottom:14px"><button class="btn" id="new-piece">+ New piece</button></div>
|
||||
${section("Core line ✨")}
|
||||
<div class="grid g2">${core.map(coreCard).join("") || '<div class="empty">the reintroduced line starts here — kitchen tools, serveware…</div>'}</div>
|
||||
${tiles.length ? section("Tiles 🔷") + `<div class="grid g2">${tiles.map(coreCard).join("")}</div>` : ""}
|
||||
${section("Small batch & one of a kind 🌙")}
|
||||
<div class="grid g2">${oneoff.map(oneoffCard).join("") || '<div class="empty">sculptural pieces, home scenting, experiments…</div>'}</div>`;
|
||||
$("#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) => `<span class="chip${v === on ? " on" : ""}" data-group="${group}" data-val="${v}">${v}</span>`).join("");
|
||||
view.innerHTML = `<button class="back" id="back">← back to pieces</button>
|
||||
<div class="card"><h4>New piece</h4>
|
||||
<div class="meta" style="margin-bottom:14px">Name is enough — price, cost, and count can arrive later.</div>
|
||||
<div class="field"><label>Name</label><input type="text" id="p-name" placeholder="e.g. Carved mug — celadon" style="width:100%"></div>
|
||||
<div class="grid g2">
|
||||
<div class="field"><label>Kind</label><div class="chips">${chip("kind", ["core line", "tiles", "small batch", "one of a kind"], "core line")}</div></div>
|
||||
<div class="field"><label>Category</label><input type="text" id="p-cat" list="cat-list" placeholder="kitchen tools, serveware, sculpture…" style="width:100%">
|
||||
<datalist id="cat-list"><option>kitchen tools</option><option>serveware</option><option>sculpture</option><option>home scent</option><option>jewelry</option><option>wood</option></datalist></div>
|
||||
<div class="field"><label>Price $</label><input type="text" id="p-price" inputmode="decimal" placeholder="0" style="width:100%"></div>
|
||||
<div class="field"><label>Cost estimate $ <span style="text-transform:none;font-weight:400">(materials etc., rough is fine)</span></label><input type="text" id="p-cost" inputmode="decimal" placeholder="0" style="width:100%"></div>
|
||||
<div class="field"><label>On hand (core line)</label><input type="text" id="p-qty" inputmode="numeric" placeholder="0" style="width:100%"></div>
|
||||
</div>
|
||||
<button class="btn" id="save-piece">Save piece</button></div>`;
|
||||
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 `<div class="loadrow"><span>${fileIcon(f.name)} <a href="/file/${f.stored}" style="color:var(--plum-deep);font-weight:600">${esc(f.name)}</a>
|
||||
<span class="meta">${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))}</span></span>
|
||||
<span>${f.tag ? `<span class="pill butter">${esc(f.tag)}</span>` : ""}
|
||||
${showHome && f.product_name ? `<span class="pill sage">🏺 ${esc(f.product_name)}</span>` : ""}
|
||||
<button class="chip" data-fdel="${f.id}" title="delete">🗑</button></span></div>`;
|
||||
}
|
||||
function uploadWidget(entity, entityId, after) {
|
||||
return {
|
||||
html: `<div class="capture mt"><div class="row1">
|
||||
<button class="btn soft" id="file-pick">⬆️ Upload file(s)</button>
|
||||
<input type="file" id="file-input" multiple hidden></div>
|
||||
<div class="chips"><span class="hint">tag as…</span>${FILE_TAGS.map((t, i) => `<span class="chip${i === 0 ? " on" : ""}" data-ftag="${t}">${t}</span>`).join("")}</div></div>`,
|
||||
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) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
|
||||
view.innerHTML = `${up.html}
|
||||
<div class="card mt">${section("Studio files 🗂️")}
|
||||
${loose.map((f) => fileRow(f, false)).join("") || '<div class="empty">STLs for master molds, foam form templates, cut files… upload anything ⬆️</div>'}</div>
|
||||
${attached.length ? `<div class="card mt">${section("Attached to pieces 🏺")}${attached.map((f) => fileRow(f, true)).join("")}</div>` : ""}`;
|
||||
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 = `<button class="back" id="back">← back to pieces</button>
|
||||
<div class="card">
|
||||
<h4>${esc(p.name)}</h4>
|
||||
<div class="mt">${p.category ? `<span class="pill butter">${esc(p.category)}</span>` : ""}
|
||||
<span class="pill sage">${esc(p.kind)}</span> <span class="pill">${money(p.price)}</span>
|
||||
${p.cost ? `<span class="pill">cost ~${money(p.cost)}</span>` : ""}</div>
|
||||
<div class="loadrow mt">${counted
|
||||
? `<span>on hand: <b>${p.qty}</b></span><span>
|
||||
<button class="chip" data-q="-1">−</button><button class="chip" data-q="1">+</button>
|
||||
<button class="btn mini" id="sell" ${p.qty < 1 ? "disabled" : ""}>sold one 💰</button></span>`
|
||||
: `<span class="chips">${STATUSES.map((s) => `<span class="chip${p.status === s ? " on" : ""}" data-st="${s}">${s}</span>`).join("")}</span>
|
||||
${p.status !== "sold" ? `<button class="btn mini" id="sell">sold 💰</button>` : ""}`}</div>
|
||||
${p.sales.length ? `<div class="result mt">💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}</div>` : ""}
|
||||
<div class="field mt"><label>Notes</label>
|
||||
<textarea id="p-notes" rows="3" style="width:100%" placeholder="dimensions, glaze combo, what makes it special…">${esc(p.notes || "")}</textarea>
|
||||
<button class="btn mini soft mt" id="save-notes">save notes</button></div>
|
||||
<div class="mt"><h3>Photos</h3><div class="photos">
|
||||
${p.photos.map((ph) => `<img src="/photos/${ph.filename}" alt="">`).join("")}
|
||||
<button class="ph-add" id="ph-add">+</button><input type="file" id="ph-file" accept="image/*" hidden></div></div>
|
||||
<div class="mt"><h3>Files — molds, templates, cut forms</h3>
|
||||
${p.files.map((f) => fileRow(f, false)).join("") || '<div class="empty">nothing attached yet</div>'}
|
||||
${up.html}</div>
|
||||
</div>`;
|
||||
$("#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) => `<h3 style="font-size:12.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--blush-deep);font-weight:700;margin:22px 0 12px">${label}</h3>`;
|
||||
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) ? ` <span title="was ${money(total(prev))}" style="color:#b3556b">▲ costs more now</span>`
|
||||
: total(e) < total(prev) ? ` <span title="was ${money(total(prev))}" style="color:#5c7157">▼ cheaper</span>` : "") : "";
|
||||
const drivable = e.supplier_kind === "local" ? ` <span class="pill sage">🚗 ${esc(e.distance || "drivable")}</span>` : "";
|
||||
return `<div class="loadrow"><span>${esc(e.supplier)}${drivable} <span class="meta">${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""}</span></span>
|
||||
<span>${money(e.price)}${e.shipping ? ` <span class="meta">+ ${money(e.shipping)} ship = <b>${money(total(e))}</b></span>` : ""}${trend}
|
||||
${e.id === bestId && currents.length > 1 ? ' <span class="pill sage">best 🏆</span>' : ""}</span></div>`;
|
||||
}).join("");
|
||||
return `<div class="card" style="margin-bottom:14px"><h4>${esc(it.name)}</h4>${rows}</div>`;
|
||||
};
|
||||
const supplierCard = (s) => `
|
||||
<div class="card"><h4>${esc(s.name)}</h4>
|
||||
<div class="mt"><span class="pill ${s.kind === "local" ? "sage" : ""}">${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"}</span>
|
||||
${s.url ? ` <a href="${esc(s.url)}" target="_blank" class="pill butter">visit site ↗</a>` : ""}</div>
|
||||
${s.notes ? `<div class="meta mt">${esc(s.notes)}</div>` : ""}</div>`;
|
||||
const searchable = suppliers.filter((s) => s.search_url);
|
||||
view.innerHTML = `
|
||||
${searchable.length ? `<div class="capture"><div class="row1">
|
||||
<input type="text" id="pc-q" placeholder="🔎 price-check an item across your suppliers — e.g. custer feldspar">
|
||||
<button class="go" id="pc-go">Search all</button></div>
|
||||
<div class="chips" id="pc-links"><span class="hint">opens each supplier's live search — real prices, real stock, then log what you see</span></div></div>` : ""}
|
||||
<div class="capture"><div class="row1">
|
||||
<input type="text" id="pe-item" placeholder="item — e.g. Custer Feldspar 50 lb" style="flex:2">
|
||||
<select id="pe-supplier">${suppliers.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join("") || "<option value=''>add a supplier first ↓</option>"}</select>
|
||||
<input type="text" id="pe-price" inputmode="decimal" placeholder="$" style="width:70px">
|
||||
<input type="text" id="pe-ship" inputmode="decimal" placeholder="ship $" style="width:80px">
|
||||
<button class="go" id="pe-save">Log price</button></div>
|
||||
<div class="chips"><span class="hint">log every buy or price-check — the comparisons build themselves 🌱</span></div></div>
|
||||
${Object.values(items).map(itemCard).join("") || '<div class="empty">no prices logged yet — start with your next order 🧭</div>'}
|
||||
${section("Suppliers")}
|
||||
<div class="grid g2">${suppliers.map(supplierCard).join("") || '<div class="empty">MN Clay, the online places…</div>'}</div>
|
||||
<div class="card mt"><h3>+ New supplier</h3>
|
||||
<div class="grid g2">
|
||||
<div class="field"><label>Name</label><input type="text" id="s-name" placeholder="e.g. Minnesota Clay" style="width:100%"></div>
|
||||
<div class="field"><label>Type</label><div class="chips">
|
||||
<span class="chip on" data-skind="online">📦 online</span><span class="chip" data-skind="local">🚗 can drive there</span></div></div>
|
||||
<div class="field"><label>Distance (if drivable)</label><input type="text" id="s-dist" placeholder="e.g. 45 min" style="width:100%"></div>
|
||||
<div class="field"><label>Website</label><input type="text" id="s-url" placeholder="https://…" style="width:100%"></div>
|
||||
<div class="field"><label>Search link <span style="text-transform:none;font-weight:400">(their search page URL with {q} where the words go)</span></label>
|
||||
<input type="text" id="s-search" placeholder="https://shop.example.com/search?q={q}" style="width:100%"></div>
|
||||
</div>
|
||||
<div class="field"><label>Notes</label><input type="text" id="s-notes" placeholder="free shipping over $150, closed Mondays…" style="width:100%"></div>
|
||||
<button class="btn" id="s-save">Save supplier</button></div>`;
|
||||
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) =>
|
||||
`<a class="chip" target="_blank" href="${esc(s.search_url.replace("{q}", encodeURIComponent(q)))}">🔎 ${esc(s.name)} ↗</a>`).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"]]) +
|
||||
`<div class="grid g2">${list.map((n) => `
|
||||
<div class="card"><p style="font-size:14.5px">${esc(n.text)}</p>
|
||||
<div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span>
|
||||
${n.tags ? `<span class="pill">${esc(n.tags)}</span>` : ""}</div></div>`).join("") ||
|
||||
'<div class="empty">no notes yet — the jot box awaits ✨</div>'}</div>`;
|
||||
wireCapture(notes);
|
||||
}
|
||||
|
||||
document.getElementById("signout")?.addEventListener("click", async () => {
|
||||
await api("/api/logout", {});
|
||||
location.href = "/login";
|
||||
});
|
||||
|
||||
render();
|
||||
110
app/public/index.html
Normal file
110
app/public/index.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>🌙 Studio Notebook</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fdf7f2; --card: #ffffff; --ink: #4a3b47;
|
||||
--plum: #8d6b94; --plum-deep: #6e4f78; --blush: #f3dbe4; --blush-deep: #c98da8;
|
||||
--sage: #8fa68a; --sage-soft: #e4ecdf; --butter: #f7ecd8; --line: #eddfe3;
|
||||
--moon: #f0e6f4;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); line-height: 1.55; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
aside { width: 212px; flex-shrink: 0; background: var(--card); border-right: 1.5px solid var(--line); padding: 26px 16px; position: sticky; top: 0; height: 100vh; }
|
||||
.wrap { flex: 1; max-width: 900px; margin: 0 auto; padding: 0 20px 80px; min-width: 0; }
|
||||
header { padding: 26px 0 0; }
|
||||
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 22px; font-weight: 600; color: var(--plum-deep); }
|
||||
.sub { color: #9c8698; font-size: 14.5px; margin-top: 2px; font-style: italic; font-family: Georgia, serif; }
|
||||
nav { display: flex; flex-direction: column; gap: 6px; margin-top: 22px; }
|
||||
nav button { border: none; background: none; color: #a08fa0; padding: 10px 14px; border-radius: 12px; font-size: 14.5px; cursor: pointer; font-weight: 600; font-family: inherit; text-align: left; }
|
||||
nav button:hover { background: var(--moon); color: var(--plum-deep); }
|
||||
nav button.on { background: var(--plum); color: #fff; }
|
||||
@media (max-width: 700px) {
|
||||
.layout { flex-direction: column; }
|
||||
aside { width: 100%; height: auto; position: static; border-right: none; border-bottom: 1.5px solid var(--line); padding: 16px 20px; }
|
||||
nav { flex-direction: row; flex-wrap: wrap; margin-top: 12px; }
|
||||
nav button { padding: 8px 14px; border-radius: 999px; background: var(--bg); }
|
||||
nav button.on { background: var(--plum); }
|
||||
}
|
||||
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; } .g3 { grid-template-columns: repeat(3, 1fr); }
|
||||
@media (max-width: 640px){ .g2, .g3 { grid-template-columns: 1fr; } }
|
||||
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 18px 20px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
|
||||
.card h3 { font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--blush-deep); font-weight: 700; margin-bottom: 12px; }
|
||||
h4 { font-family: Georgia, serif; font-size: 17px; font-weight: 600; color: var(--plum-deep); }
|
||||
.meta { color: #9c8698; font-size: 13.5px; }
|
||||
.capture { background: var(--card); border: 1.5px solid var(--line); border-radius: 18px; padding: 14px 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(141,107,148,0.06); }
|
||||
.capture .row1 { display: flex; gap: 10px; }
|
||||
input[type=text], textarea, select { border: 1.5px solid var(--line); border-radius: 12px; padding: 11px 14px; font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
|
||||
.capture input[type=text] { flex: 1; }
|
||||
button.go, .btn { border: none; border-radius: 12px; padding: 11px 20px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; background: var(--plum); color: #fff; }
|
||||
.btn.soft { background: var(--blush); color: var(--plum-deep); }
|
||||
.btn.mini { padding: 6px 14px; font-size: 13px; }
|
||||
.chips { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.chips .hint { font-size: 12.5px; color: #b1a0ae; }
|
||||
.chip { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); border: 1.5px solid transparent; cursor: pointer; }
|
||||
.chip.on { border-color: var(--plum); background: var(--plum); color: #fff; }
|
||||
.todo { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; }
|
||||
.todo:last-child { border: none; }
|
||||
.todo input { width: 17px; height: 17px; accent-color: var(--sage); flex-shrink: 0; }
|
||||
.todo .done { text-decoration: line-through; color: #b1a0ae; }
|
||||
.pill { display: inline-block; font-size: 12px; font-weight: 650; padding: 3px 11px; border-radius: 999px; background: var(--blush); color: var(--plum-deep); }
|
||||
.pill.sage { background: var(--sage-soft); color: #5c7157; }
|
||||
.pill.butter { background: var(--butter); color: #8d6a52; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th { text-align: left; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #b1a0ae; font-weight: 700; padding: 4px; }
|
||||
th:last-child, td:last-child { text-align: right; }
|
||||
td { padding: 7px 4px; border-bottom: 1px solid var(--line); }
|
||||
tr:last-child td { border: none; }
|
||||
.swatch { height: 64px; border-radius: 12px; border: 1px solid rgba(0,0,0,0.05); margin-bottom: 10px; }
|
||||
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; font-size: 14px; margin-top: 10px; }
|
||||
.sparkle { background: var(--sage-soft); border-radius: 12px; padding: 10px 14px; font-size: 13.5px; color: #55624c; margin-top: 12px; }
|
||||
.photos { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.photos img { width: 72px; height: 72px; border-radius: 10px; object-fit: cover; }
|
||||
.ph-add { width: 72px; height: 72px; border-radius: 10px; border: 1.5px dashed var(--blush-deep); color: var(--blush-deep); font-size: 26px; background: none; cursor: pointer; }
|
||||
.mt { margin-top: 14px; }
|
||||
.clickable { cursor: pointer; }
|
||||
.verdict { display: flex; gap: 5px; }
|
||||
.verdict button { font-size: 16px; padding: 4px 9px; border-radius: 10px; background: var(--bg); border: 1.5px solid var(--line); cursor: pointer; }
|
||||
.verdict button.on { background: var(--sage-soft); border-color: var(--sage); }
|
||||
.loadrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; flex-wrap: wrap; }
|
||||
.loadrow:last-child { border: none; }
|
||||
.empty { color: #b1a0ae; font-style: italic; font-size: 14px; padding: 8px 0; }
|
||||
.back { background: none; border: none; color: var(--plum); font-weight: 700; cursor: pointer; font-size: 14px; font-family: inherit; padding: 0 0 10px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px; }
|
||||
.matrow { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.matrow input:first-child { flex: 1; }
|
||||
.matrow input:last-of-type { width: 80px; }
|
||||
footer { text-align: center; color: #b1a0ae; font-size: 13px; margin-top: 32px; font-style: italic; font-family: Georgia, serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<h1>🌙 Studio<br>Notebook</h1>
|
||||
<nav id="nav">
|
||||
<button data-v="today" class="on">☀️ Today</button>
|
||||
<button data-v="glazes">🧪 Clay & Glazes</button>
|
||||
<button data-v="firings">🔥 Firing Log</button>
|
||||
<button data-v="pieces">🏺 Pieces</button>
|
||||
<button data-v="files">🗂️ Files</button>
|
||||
<button data-v="sourcing">🧭 Sourcing</button>
|
||||
<button data-v="notes">📒 Notes</button>
|
||||
</nav>
|
||||
<nav style="margin-top:18px"><button id="signout" style="font-size:13px">👋 sign out</button></nav>
|
||||
</aside>
|
||||
<div class="wrap">
|
||||
<header style="padding-bottom:18px">
|
||||
<div class="sub" id="greeting">welcome back</div>
|
||||
</header>
|
||||
<main id="view"></main>
|
||||
<footer>🌙 Studio Notebook · bms.poweredbyotm.com</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
app/public/login.html
Normal file
47
app/public/login.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>🌙 Studio Notebook — sign in</title>
|
||||
<style>
|
||||
:root { --bg: #fdf7f2; --card: #fff; --ink: #4a3b47; --plum: #8d6b94; --plum-deep: #6e4f78;
|
||||
--blush-deep: #c98da8; --line: #eddfe3; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Avenir Next", "Nunito", "Trebuchet MS", sans-serif; background: var(--bg);
|
||||
color: var(--ink); min-height: 100vh; display: grid; place-items: center; }
|
||||
.card { background: var(--card); border: 1.5px solid var(--line); border-radius: 20px;
|
||||
padding: 34px 36px; width: min(92vw, 380px); box-shadow: 0 4px 16px rgba(141,107,148,0.1); }
|
||||
h1 { font-family: Georgia, serif; font-size: 24px; color: var(--plum-deep); margin-bottom: 4px; }
|
||||
.sub { color: #9c8698; font-size: 14px; font-style: italic; font-family: Georgia, serif; margin-bottom: 22px; }
|
||||
label { display: block; font-size: 12px; font-weight: 700; color: var(--blush-deep);
|
||||
text-transform: uppercase; letter-spacing: 0.08em; margin: 14px 0 5px; }
|
||||
input { width: 100%; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px 15px;
|
||||
font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
|
||||
button { width: 100%; margin-top: 22px; border: none; border-radius: 12px; padding: 13px;
|
||||
font-size: 15px; font-weight: 700; cursor: pointer; background: var(--plum); color: #fff; font-family: inherit; }
|
||||
.err { color: #b3556b; font-size: 13.5px; margin-top: 12px; min-height: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🌙 Studio Notebook</h1>
|
||||
<div class="sub">welcome back to the studio</div>
|
||||
<form id="f">
|
||||
<label>Email</label><input type="email" id="email" autocomplete="username" required>
|
||||
<label>Password</label><input type="password" id="password" autocomplete="current-password" required>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="err" id="err"></div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("f").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: email.value, password: password.value }) }).then((x) => x.json());
|
||||
if (r.ok) location.href = "/";
|
||||
else document.getElementById("err").textContent = r.error || "sign-in failed";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
65
app/scripts/migrate-sqlite.mjs
Normal file
65
app/scripts/migrate-sqlite.mjs
Normal file
@@ -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 <studio.db>"); 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();
|
||||
18
app/scripts/seed-owner.mjs
Normal file
18
app/scripts/seed-owner.mjs
Normal file
@@ -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();
|
||||
BIN
app/server.mjs
Normal file
BIN
app/server.mjs
Normal file
Binary file not shown.
Reference in New Issue
Block a user