From cd83fd61cb7a7b16c5ebe098b9e6a436c1361ee8 Mon Sep 17 00:00:00 2001 From: Bonna Date: Thu, 6 Aug 2026 21:46:54 -0500 Subject: [PATCH] Studio Notebook: vision, Phase 1 designs, and working prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VISION.md: the full studio-backbone product vision (craft core, operations, selling, daily driver), Bonna's approved design decisions, and phasing - design/: two clickable Phase 1 mockups (moonlit palette, CSS-only tabs) - prototype/: zero-dependency local app (Node built-ins + SQLite) — Today with jot-first capture + subtask outliner, Clay & Glazes library, fill-and-go Firing Log with emoji unload ratings feeding evidence tables, Pieces with core line/tiles/one-of-a-kind + money card, Files with per-piece attachment, Sourcing with price ledger + live supplier search links (9 suppliers seeded) - CLAUDE.md: access notes updated for the provisioned site + this repo's state Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 + .gitignore | 3 + CLAUDE.md | 104 ++++ Start Studio Notebook.command | 6 + VISION.md | 170 ++++++ design/phase1-glaze-firing-screens.html | 295 +++++++++++ design/phase1-studio-notebook-mockup.html | 251 +++++++++ prototype/README.md | 73 +++ prototype/public/app.js | 612 ++++++++++++++++++++++ prototype/public/index.html | 109 ++++ prototype/server.mjs | 282 ++++++++++ 11 files changed, 1916 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100755 Start Studio Notebook.command create mode 100644 VISION.md create mode 100644 design/phase1-glaze-firing-screens.html create mode 100644 design/phase1-studio-notebook-mockup.html create mode 100644 prototype/README.md create mode 100644 prototype/public/app.js create mode 100644 prototype/public/index.html create mode 100644 prototype/server.mjs diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..073ca5b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "studio-notebook", + "runtimeExecutable": "node", + "runtimeArgs": ["prototype/server.mjs"], + "port": 6161 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0fefbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +prototype/data/ +.claude/settings.local.json +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..354f35e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# Bonna Moon Studio — access notes + +Status as of 2026-08-06: **provisioned and live** at +https://bonnamoonstudio.poweredbyotm.com (`site` template v0.11.0, container +`site-bonnamoonstudio` on anvil). The repo remains empty by design — see "The plan" +below. This file covers how to reach things. There is no app code here to document. + +The long-term product vision — a custom "studio backbone" app in the FMB mold +(glaze recipes, firing log, inventory, accounting, market scanner, kiln control) — +is captured in [VISION.md](VISION.md). The `site` instance is the storefront layer; +the backbone is a separate, phased build. + +## The repo + +- Gitea: `https://git.poweredbyotm.com/bonna61/Bonna-Moon-Studio.git` +- Remote name here is **`gitea`** (not `origin`) — matches the FMB convention. +- Local branch is `master`; FMB and the platform both use **`main`**. Rename before + the first commit, or let `git checkout main` create the tracking branch after the + remote has one. + +```sh +cd "/Users/bonnamoon/Projects/Bonna Moon Studio" +git fetch gitea && git checkout main # once the remote has content +``` + +**Auth: macOS keychain.** There is no `~/.gitconfig` and no `~/.git-credentials` on +this machine; `credential.helper=osxkeychain` is set at the git *system* level. A +`git ls-remote gitea` completes with no prompt, so pushes and fetches over +`https://git.poweredbyotm.com` just work. Nothing needs a token in the URL. + +Commit identity is set **locally** in this repo (there's no global fallback — commits +would fail without it): + +``` +user.name = Bonna +user.email = bonna@fullmoonbakehouse.com +``` + +### The other Gitea address + +Same Gitea instance, three addresses depending on where you are: + +| Address | When it works | +|---|---| +| `https://git.poweredbyotm.com` | Anywhere. Keychain auth. Use this. | +| `http://192.168.0.5:3022` | Home LAN only (NAS). | +| `http://167.237.9.184:3022` | Home public IP — what `anvil` uses, and the fallback when the Mac is off the LAN. | + +The sibling repos (`moonbase`, `moon-household-budget`, `otm-platform`) embed an +access token directly in their remote URL against `192.168.0.5:3022`. Those tokens +are **only** recorded in those URLs — the `~/Projects/CLAUDE.md` that +[moonbase/docs/git-deploy-guide.md](../moonbase/docs/git-deploy-guide.md) refers to +does not exist on this machine. Read one back with: + +```sh +git -C /Users/bonnamoon/Projects/moonbase remote get-url origin +``` + +Never commit a token. + +## Hosts + +- **`anvil`** = `debian@51.81.80.250` — OVH VPS, **production, live**. Serves + `*.poweredbyotm.com` with real Let's Encrypt certs. Wildcard `*` A-record, so any + new `.poweredbyotm.com` resolves with zero DNS work. Host user is `debian`, + not `tonym`. Can't reach the home LAN — its builds use `167.237.9.184:3022`. +- **`forge`** = `tonym@192.168.0.83` — Unraid VM, staging, `*.local` hostnames with a + local CA. Needs the home LAN. +- OTM Admin: **https://admin.poweredbyotm.com** (anvil) — provisioning, template + builds, per-service ops, logs. + +A connected MCP server exposes the same control plane read/write (`platform_status`, +`list_instances`, `list_templates`, `onboard`, deploy + log tools). Call +`platform_status` or `list_instances` first for an overview. It points at **live +production** — treat `onboard`, deploys, and restarts as real-world actions. + +Platform source: `/Users/bonnamoon/Projects/platform` (and `otm-platform`). Its +`CLAUDE.md` is the authority on deploys, the CI-gated auto-deploy webhook, and the +long list of anvil-vs-forge gotchas. + +## The plan (executed 2026-08-06) + +One app with public pages **plus** an `/admin` back office — the **`site`** template +("Web — hosting + site + account", v0.11.0), same as `home`, `overthemoon`, and +`vvts`. Public site plus the drag-and-drop CMS page editor from `@otm/account-panel`. + +Provisioned via `onboard`: customer **Bonna Moon Studio**, app/service slug +`bonnamoonstudio`, industry `universal`, owner **bonna@bonnamoonstudio.com**. +Live at **https://bonnamoonstudio.poweredbyotm.com** (admin at `/admin`). The +seeded owner password was captured and handed to Bonna at provisioning time; it is +deliberately not recorded anywhere in this repo. + +What it is: Bonna's **art studio** — ceramics, carved wood, and sterling +silver/ceramic jewelry. The prior site at bonnamoonstudio.com (shop temporarily +closed during the studio rebuild) has the source content: product lines, collector +testimonials, newsletter signup. That domain still points at the old site; cutover +is a future DNS step. + +Remaining notes: + +- **The `site` template does not use this repo.** Its code lives in the platform + monorepo at `apps/site`; per-instance content lives in that instance's Postgres, not + in git. This repo staying empty is expected. Giving it real code means authoring a + new platform template and build chain — days, not minutes. diff --git a/Start Studio Notebook.command b/Start Studio Notebook.command new file mode 100755 index 0000000..bb5d99a --- /dev/null +++ b/Start Studio Notebook.command @@ -0,0 +1,6 @@ +#!/bin/zsh +# Double-click me to start the Studio Notebook 🌙 +cd "$(dirname "$0")" +open -g "http://localhost:6161" 2>/dev/null || true +(sleep 1 && open "http://localhost:6161") & +exec node prototype/server.mjs diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..782814f --- /dev/null +++ b/VISION.md @@ -0,0 +1,170 @@ +# Bonna Moon Studio — the system Bonna actually wants + +Captured 2026-08-06, in Bonna's words: "a BEAST of a system that is SUPER easy to +use for me day to day... cast a large net and then condense functions down until +they're easy to use... I'm looking to build an EXTENSIVE thing." + +This is a **studio backbone** in the FMB sense — a custom back-office app, not the +`site` template plus modules. FMB's backbone (recipes → costed ingredients → stock → +orders → schedule → margin/break-even) is the architectural blueprint; this maps +bakery concepts to a ceramics + woodworking studio. + +The public site (live at bonnamoonstudio.poweredbyotm.com, `site` template) stays as +the storefront/marketing layer regardless. + +## The feature net (everything named so far) + +### Studio craft core +- **Glaze recipe library** — store glaze recipes; the FMB `recipe_cost` analogue. +- **Slipcasting & molds** (added 2026-08-06 — Bonna is getting into this soon): + - **Mold library** — molds are durable studio assets, like glazes: each gets a + page with photos, what it casts, number of parts, plaster notes, and a + **cast counter** (plaster molds wear out — the app should know a mold's age). + - **Casting slip recipes** — same recipe system as glazes but a distinct type: + slips care about specific gravity AND viscosity/deflocculant (Darvan etc.), + and get their own journal (cast times, slip condition). + - **Casting log entries** — analogous to firings: which molds, which slip, + cast time, results. + - Pieces gain a "how it was made" (thrown / handbuilt / carved / slipcast / + wood) — matters for provenance, pricing, and the learning tables. + - Mold-making shopping: plaster, cottle boards, sodium silicate/Darvan — the + one shopping list absorbs all of it. + Phase 1 schema implication: "recipes" are typed (glaze | slip | …) and molds + are first-class assets from day one, even if mold screens land a bit later. +- **Welding / metal sculpture** (added 2026-08-06 — Bonna is "VERY very very + new"): no dedicated screens yet — the generic bones carry it. Notes with + photos and a "welding" tag become the practice journal (beginner logs are + gold later); steel stock, rod, gas, and consumables go in pantry + shopping + list; pieces' "how it was made" gains `welded`. If it grows into a serious + line, it earns its own module later (settings that worked, joint notes, + material costs per sculpture) — same pattern as everything else: start as + notes, graduate to structure when the data asks for it. +- **Firing log** — notes on firings; over time links glazes ↔ clay bodies ↔ cone/ + schedule ↔ results. +- **Clay body ↔ glaze compatibility** — the system *learns* what works together, + fed by the firing log. (The "learning" is structured data + photos first; smart + suggestions later.) +- **Glazy integration** — hook into glazy.org (the open glaze-recipe database) for + recipes, chemistry, and photos of fired results. +- **Kiln control** — Tony is programming a kiln controller; the backbone should + eventually schedule/monitor/fire it. Interface TBD with Tony (hardware project, + separate repo presumably). + +### Studio operations +- **Inventory** — both raw materials (clay, glaze chemicals, wood stock) and + finished pieces; back-end tracking AND shop-facing availability. +- **Cost tracking** — materials cost per piece, per firing, per line. +- **Accounting** — "like we have on FMB"; full income/expense books. +- **Break-even calculator** — FMB has one; generalize it, esp. for market/show + applications (fees + travel vs expected sales). + +### Selling & shows +- **Market scanner** — scan local markets/art fairs/shows worth applying to; + surface deadlines, application costs, distance to travel. Paired with the + break-even calc for a "worth it?" verdict. + +### Daily driver +- **Notes** — extensive, effortless note-taking with photos (studio notes, piece + notes, firing notes all interlink). +- **To-do list** — "a really good one." +- **Calendar** — firings, markets, deadlines, commissions. + +## Design principle + +Cast a large net, then **condense until easy**. Day-to-day ease of use beats +feature count. Every screen should answer "what do I need to do/know right now." + +Key context: Bonna has been away from her art for ~2 years and is building out a +brand-new studio space. There is **no existing workflow to preserve** — the app +and the studio habits get designed together, from scratch. The system's job is to +be the "solid organizational net" under her return: she should be able to jump +back in and trust that recipes, firings, notes, and tasks are held somewhere that +isn't her head. Design for re-entry: low friction, forgiving, encouraging. + +## Open architecture questions (for Tony + Bonna) + +1. **Where does the code live?** Platform templates live in the platform monorepo + with a build chain (per CLAUDE.md, authoring a new template = days). Options: + new `bms-backbone` template in the monorepo (FMB convention), or app code in + this repo with its own build. FMB convention suggests the former. +2. **One app or two?** Backbone (admin/ops) + existing `site` storefront, like + FMB + fullmoonbakehouse.poweredbyotm.com. Probably: keep them separate, + integrate inventory → shop later. +3. **Kiln controller interface** — what protocol/API will Tony's controller + expose? Drives how "fire my kiln" lands in the app. +4. **Market scanner data source** — scraping? manual seed list + assisted search? + This one needs a feasibility pass; deadlines/fees aren't in any clean API. + +## Prototype (built 2026-08-06) + +Bonna's product structure (2026-08-06): the reintroduced **core line** (solid +kitchen tools, serveware) alongside **small batch / one of a kind** work +(sculptural pieces, home scenting). The app tracks them "together but +separate" — one Pieces tab, distinct sections and behaviors (counts vs +statuses). Money tracking starts as price + rough cost estimate per piece with +one-tap sales; FMB-style true costing comes with Phase 2 accounting. + +Phase 1 exists as a **working local prototype** in [prototype/](prototype/) — +zero dependencies (Node built-ins only), runs via `Start Studio Notebook.command` +in the repo root, data in gitignored `prototype/data/`. See +[prototype/README.md](prototype/README.md) for scope. It implements the approved +decisions above (jot-first, fill-and-go firings, emoji unload ratings, +evidence-only compatibility, batch scaling) and serves as the schema/UX +reference for the production build. + +## Rough phasing (to refine) + +- **Phase 1 — the notebook**: glaze recipes, firing log, notes with photos, + materials list. (Highest daily value, zero external dependencies, and it starts + accumulating the data the "learning" features need.) A clickable design sketch + Bonna liked lives at + [design/phase1-studio-notebook-mockup.html](design/phase1-studio-notebook-mockup.html) + — soft plum/blush/sage "moonlit studio" palette, Today/Glazes/Firing Log/Notes + tabs, jot-box-first capture with optional "file it as…" chips. Note: it uses + CSS-only tabs deliberately (preview panes strip scripts). + + Deep screens sketched in + [design/phase1-glaze-firing-screens.html](design/phase1-glaze-firing-screens.html): + a glaze's detail page, new-glaze form, start-a-firing, and unload day. + **Decisions Bonna has approved:** + - Capture model: jot-first, optional one-tap "file it as…" chips (note / glaze / + firing / to-do / piece). + - Glaze page: recipe as % + scaled batch weights (500g/1kg/2kg/5gal); a + "plays well with" clay-body table built ONLY from unload-day evidence, never + edited directly; running journal; photo wall; **Stull chart** ("the chemistry + map") computed from the recipe with region overlay (crazed/gloss/semi-matte/ + matte/under-fired) — Glazy-style, regions adjusted per cone; other glazes + appear as faded dots. + - Firing lifecycle: plan → load → fire → unload, as explicit steps. Load rows + are the piece + clay + glaze triple (this feeds the compatibility learning). + Kiln-photo-before-closing nudge. + - Unload day: one-tap rating per item, emoji scale 😍🙂😕💔 — approved "for + now, maybe change in production." One save fans out: pieces → ready-to-sell, + glaze pages gain evidence rows, firing closes. + - New glaze: name-only minimum; cone/atmosphere/surface/source as chips; + materials autocomplete from pantry (ties to inventory later); "Save & mix a + test batch" secondary action; Glazy paste-to-import deferred to Phase 4. + - **Shopping list** (added at Bonna's request): ONE running list for + everything — glaze materials, tools, packaging, business cards, stickers, + paper goods. Lives on Today; items arrive from jots ("order more wax + resist"), from pantry-low signals, or typed directly. Phase 1 scope. + - **Firing flow must not be fussy** — Bonna: "the fussier it is, the less + likely I am to use the tool… more of a fill the kiln and go kind of girl." + The plan→load→fire→unload steps are a trail, not a form: a firing can be + started with nothing but type+cone, and the load can be described later, + even at unload. Efficient load *planning* is a someday-nice-to-have, never + required. + - Context on glaze chemistry: Bonna took a course with Katz (Ceramic + Materials Workshop) — solid theory, thin practice. Don't dumb the chemistry + down; do build experience-confidence (the Stull chart and evidence tables + serve exactly this). +- **Phase 2 — the business**: inventory (materials + pieces), cost tracking, + break-even calc, accounting. +- **Phase 3 — the calendar & todos**: calendar, to-do list, market/show deadline + tracking (manual entry first). +- **Phase 4 — the reach**: market scanner, Glazy integration, shop-facing + inventory on the public site. +- **Phase 5 — the kiln**: controller integration when Tony's hardware is ready. + +Phasing is by dependency and daily value, not importance — the kiln being last +doesn't mean it matters least. diff --git a/design/phase1-glaze-firing-screens.html b/design/phase1-glaze-firing-screens.html new file mode 100644 index 0000000..5a1b376 --- /dev/null +++ b/design/phase1-glaze-firing-screens.html @@ -0,0 +1,295 @@ + + + + + +Glaze & Firing screens — Phase 1 sketch + + + + + + + + +
+
+ +

🌙 Glazes & Firings — the deep screens

+
a glaze's home page · adding a new one · loading the kiln · unload day
+
+ + + + +
+
+
+
+
+

Sea-glass Celadon

+
∆6 oxidation · glossy · translucent · dip 3s ×3
+
+ 7 firings logged + S.G. 1.45 + from my old notebook +
+
+
+
+
+

Recipe · tap a number to edit

+ + + + + + + + +
Material%2,000 g batch
Custer Feldspar38760 g
Silica28560 g
Whiting18360 g
EPK Kaolin16320 g
+ Copper Carbonate0.510 g
+ Bentonite240 g
+
⚖️ scale batch: 500g · 1kg · 2kg · 5 gal
+
+
+

Plays well with…

+ + + + + + +
Clay bodyVerdictEvidence
B-Mix♥ lovely4 firings
Speckled Brownstone✓ good2 firings
Porcelain? untested
Dark stoneware✗ pinholes1 firing
+
🌱 This table grows by itself — every unload day where you rate a piece adds a row of evidence. You never fill it in directly.
+
+
+
+

Journal

+
📒 Jul 12 — thin = washes out. 3 dips or S.G. 1.45. Pools gorgeously in carved lines.
+
📒 Jun 30 — variation #2 with +1% copper: too dark, muddy. Staying with original.
+
+
+

Test tiles & pieces

+
🧪
🏺
🫖
🥣
+
+
+ +
+

Where it lands — the chemistry map · the Stull chart, like Glazy's

+ + + + + + + + + under-fired + crazed + bright gloss + semi-matte + matte + + + + alumina (Al₂O₃) → + silica (SiO₂) → + 0.2 + 0.4 + 0.6 + 2 + 3 + 4 + 5 + + + Toasted Amber + + Oat Matte + + + Sea-glass Celadon + +
🧮 Computed from the recipe automatically — no data entry. The dot says why it's glossy; nudge silica or alumina and watch the dot move before you mix anything. Faded dots are your other glazes, so the map doubles as a family portrait. (Regions shift with cone — the real chart adjusts to ∆6.)
+
+
+ + +
+
+

New glaze

+
Only the name is required — everything else can arrive later.
+
+
+
+
∆04∆6∆10other
+
+
+
oxidationreduction
+
+
+
glossysatinmatte
+
+
+
mineGlazybook / friend
+
+
+
+ + + + +
Nepheline Syenite45
Silica25
+ add material (autocompletes from your pantry)
+
+
+ + +
💡 Later (Phase 4): paste a Glazy link and the recipe fills itself in.
+
+
+ + +
+
+
+ 1 · plan2 · load3 · fire4 · unload + ← a trail, not a form. Skip everything. +
+

Firing #5

+
🔥 Fill-the-kiln-and-go mode: tap start firing with nothing filled in — just "glaze ∆6, go." Tell it what went in later, even on unload day. The load list below is for when you feel like it, not homework.
+
+
+
bisque ∆04glaze ∆6
+
+
+
slow ramp + 15 min holdstandardcustom…
+
+
+

What's going in · piece + clay + glaze — this is the magic combo

+
🏺 6 carved mugsB-Mix · Sea-glass Celadon
+
🥣 2 serving bowlsSpeckled Brownstone · Toasted Amber
+
🧪 Test tiles ×4Porcelain · Sea-glass Celadon first porcelain test!
+
+ add from "in the studio" list
+
+

Kiln photo before closing

+
📷
+
Future-you, on unload day, will be very glad this exists.
+
+
+ +
+
🔌 Phase 5: this button actually talks to Tony's kiln controller — ramp status and cone-offs show up right here.
+
+
+ + +
+
+
+ 1 · plan2 · load3 · fire4 · unload ✨ +
+

Firing #5 — unload day!

+
Rate each thing as it comes out. One tap each — notes only where you want them.
+
+ 🏺 Carved mugs B-Mix · Celadon + 😍🙂😕💔 +
+
+ 🥣 Bowls Brownstone · Amber + 😍🙂😕💔 +
+
+ 🧪 Tiles Porcelain · Celadon + 😍🙂😕💔 +
+
+
+

Photos

+
📷
📷
📷
+
+
+
🌱 Saving this updates everything automatically: the mugs move to "ready to sell," Celadon's page gains a porcelain row ("😕 washed out — 1 firing"), and Firing #5 closes with its story told. One screen, three systems fed.
+
+
+ +
🌙 round 3 · the deep screens · what would make unload day feel like opening presents?
+
+ + diff --git a/design/phase1-studio-notebook-mockup.html b/design/phase1-studio-notebook-mockup.html new file mode 100644 index 0000000..e13aeda --- /dev/null +++ b/design/phase1-studio-notebook-mockup.html @@ -0,0 +1,251 @@ + + + + + +Studio Notebook — Phase 1 sketch + + + + + + + + +
+
+ +

🌙 Studio Notebook

+
Good morning, Bonna — day 3 in the new studio
+
+ + + + +
+
+
+ + + +
+
+ file it as… + ✨ just a note🧪 glaze🔥 firing✓ to-do🏺 piece +
+
+
+
+

To-do

+
Wedge clay for mug run (B-Mix)
+
Mix test batch — Celadon variation #2
+
Unload bisque firing
+
Order silica — running low
+
+
+

In the studio

+
🏺 6 mugs — leather hard, trim today
+
🥣 2 serving bowls — drying, 2 more days
+
🥄 Walnut spoon blanks — roughed out
+
next firing: bisque, ~Sat
+
+
+

Kiln

+
Idle — last fired 3 days ago (bisque, ∆04)
+
Tony's controller plugs in here, Phase 5
+
+
+

Recent notes

+
“Celadon breaks beautifully over carved texture…”
+
“New wheel position feels right by the window”
+
+
+

Shopping list 🧺

+
Silica — 10 lb pantry says low
+
Business cards — reorder
+
Tissue paper + moon stickers
+
Trimming tool (the sharp little one)
+
one list for everything — materials, tools, packaging, paper goods
+
+
+

How things land here

+
✍️ Jot “order more wax resist” anywhere → chip it as to-do or say “shopping” → it files itself onto this list.
+
+
+
+ + +
+
+
+
+

Sea-glass Celadon

+
∆6 ox · glossy · translucent
+ tested · loves B-Mix +
+
+
+

Toasted Amber

+
∆6 ox · satin · breaks rust
+ 2 tests · crawls on porcelain +
+
+
+

Oat Matte

+
∆6 ox · matte · opaque
+ untested — from Glazy +
+
+
+

Recipe — Sea-glass Celadon

+ + + + + + + +
Custer Feldspar38
Silica28
Whiting18
EPK Kaolin16
+ Copper Carbonate0.5
+ Bentonite2
+
📒 Notebook: thin = washes out; 3 dips or S.G. 1.45. Gorgeous pooling in carved lines. Works with: B-Mix ✓, Speckled Brownstone ✓, Porcelain — untested.
+
🏺
🫖
🧪
+
+
+ + +
+
+

Firing #4 — Glaze, ∆6 oxidation

+
Aug 2 · slow ramp, 15 min hold · 11 hrs
+
    +
  • 6 mugs — B-Mix — Sea-glass Celadon
  • +
  • 2 bowls — Speckled Brownstone — Toasted Amber
  • +
  • 4 test tiles — Oat Matte variations
  • +
+
Result: Celadon perfect on carved mugs. Amber ran on bowl #2 — thinner next time or catch plates. Oat tiles: #3 is the keeper.
+
📷
📷
+
+
+

Firing #3 — Bisque, ∆04

+
Jul 29 · standard bisque · 9 hrs
+
  • Full load — mugs, bowls, tiles
+
All clean, no cracks. New kiln shelves level. 🎉
+
+
+ + +
+
+
+ + + +
+
+ tag it… + glazewoodnew studiocollection ideasshows+ new tag +
+
+
+
+

Carving + celadon = the thing

+

The break over carved texture is exactly the look. Lean the whole first collection into carved surfaces. Chattering tool next?

+
🏺
+
glazecollection ideas
+
+
+

Studio layout

+

Wheel by the window is right. Wedging table needs to move closer to clay storage — too many trips.

+
new studio
+
+
+

Spoon shapes

+

Walnut roughing went fast. Try a shorter, deeper bowl on the next blanks — more of a tasting spoon.

+
🥄
🪵
+
wood
+
+
+

Market idea — Fall Makers Fair

+

Deadline usually mid-September? Check fee. This becomes a real tracked thing in Phase 3.

+
shows
+
+
+
+ +
🌙 Phase 1 sketch · tell me what feels right and what doesn't
+
+ + diff --git a/prototype/README.md b/prototype/README.md new file mode 100644 index 0000000..c92987d --- /dev/null +++ b/prototype/README.md @@ -0,0 +1,73 @@ +# Studio Notebook — Phase 1 prototype + +A zero-dependency local prototype of the studio backbone's Phase 1 (see +[../VISION.md](../VISION.md)). Node's built-in `http` + `node:sqlite` — no npm +install, no node_modules, total footprint a few hundred KB plus photos. + +**Run it:** double-click `Start Studio Notebook.command` in the repo root, or: + +```sh +node prototype/server.mjs +``` + +Then open http://localhost:6161. Data lives in `prototype/data/` (gitignored): +`studio.db` (SQLite) and `photos/`. Back that folder up = back up the notebook. + +## What works (v0.1) + +- **Today** — jot box with "file it as…" chips (note / to-do / shopping / glaze + idea), to-dos **with subtasks**, the one shopping list, kiln status, recent notes +- **Keyboard-first capture** — Enter saves and stays in the box; Shift+Enter + adds subtask lines (first line = task, rest = its subtasks); multiline + shopping jots become one item per line; Tab cycles the file-as chips; + `/` focuses the jot box from anywhere; per-todo + opens an inline subtask + input (Enter saves, Esc closes) +- **Clay & Glazes** — one library, three kinds: glazes, casting slips, and clay + bodies, each grouped under its own heading; clay bodies feed an autocomplete + in the firing form so compatibility evidence never splits over spelling; + cards with swatches; new glaze/slip form (name-only minimum, + chips for cone/atmosphere/surface/source); detail page with recipe, + batch scaling (500 g–5 kg), journal, photo uploads, and the **"plays well + with" table built exclusively from unload-day ratings** (never hand-edited) +- **Firing Log** — fill-the-kiln-and-go start (type + cone, everything else + optional); load items (piece + clay + glaze) addable any time incl. unload; + **unload day** with 😍🙂😕💔 one-tap ratings that feed the glaze evidence +- **Notes** — the jot stream with tags +- **Pieces** — Bonna's "together but separate" product tracking: **Core line** + (repeatable products with on-hand counts, − / + / "sold one") vs **Small + batch & one of a kind** (individual pieces with in progress → for sale → sold + status). Each piece carries price + rough cost estimate. +- **Money** — sales recorded on one tap; the money card shows this-month + revenue, all-time revenue, and estimated profit (sales − cost estimates). + Real materials-based costing is a later phase. +- **Sidebar navigation** (FMB-style) on desktop; collapses to top chips on + narrow screens. +- **Piece pages** (FMB recipe/listing-style) — click any piece name for its + page: price/cost/stock or status controls, sales history, notes, photos, and + attached files. +- **Files** — upload anything (STL master molds, foam form templates, cut + files); tag as master molds / foam forms / prototypes / other; attach to a + specific piece from its page, or keep in the general studio files area. + Downloads preserve original filenames. Stored in `prototype/data/files/`. +- **Sourcing** — suppliers (online vs 🚗 drivable-with-distance, website, + shipping notes) + a price ledger: log every buy or price-check (item, + supplier, price, shipping). Entries group by item; each shows ▲/▼ trend vs + that supplier's previous price, shipping-inclusive totals, and a "best 🏆" + pill on the cheapest current source. Suppliers may carry a search-URL + template (`…?q={q}`); the price-check box then opens any item's live search + on every such supplier in one click. Fully-automated scraping is deliberately + out (fragile, silently stale) — live-open links + the ledger habit instead; + Claude can also be asked to browse suppliers and log price checks. + +## Deliberately not here yet + +Stull chart (needs UMF chemistry data — Phase 1.5), molds/slip-casting screens, +inventory, costs, calendar, market scanner, Glazy import, kiln control. See +VISION.md phasing. + +## Status + +Prototype for design validation + real daily use while the platform question +(where the production code lives — Tony's call, see VISION.md "Open architecture +questions") is pending. The schema here (typed recipes, evidence-only +compatibility, firing lifecycle) is the reference for the production build. diff --git a/prototype/public/app.js b/prototype/public/app.js new file mode 100644 index 0000000..c7fe507 --- /dev/null +++ b/prototype/public/app.js @@ -0,0 +1,612 @@ +// 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); + 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); +} + +render(); diff --git a/prototype/public/index.html b/prototype/public/index.html new file mode 100644 index 0000000..91197d8 --- /dev/null +++ b/prototype/public/index.html @@ -0,0 +1,109 @@ + + + + + +🌙 Studio Notebook + + + +
+ +
+
+
welcome back
+
+
+
🌙 Studio Notebook · Phase 1 prototype · your data lives safely on this Mac
+
+
+ + + diff --git a/prototype/server.mjs b/prototype/server.mjs new file mode 100644 index 0000000..070ff0d --- /dev/null +++ b/prototype/server.mjs @@ -0,0 +1,282 @@ +// Studio Notebook — Phase 1 prototype server +// Zero dependencies: Node's built-in http + sqlite. Run: node prototype/server.mjs +import { createServer } from "node:http"; +import { DatabaseSync } from "node:sqlite"; +import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "node:fs"; +import { join, dirname, extname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const DATA = join(ROOT, "data"); +const PHOTOS = join(DATA, "photos"); +mkdirSync(PHOTOS, { recursive: true }); + +const db = new DatabaseSync(join(DATA, "studio.db")); +db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY, text TEXT NOT NULL, tags TEXT DEFAULT '', + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0, + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS shopping ( + id INTEGER PRIMARY KEY, text TEXT NOT NULL, done INTEGER DEFAULT 0, + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS glazes ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'glaze', + cone TEXT DEFAULT '', atmosphere TEXT DEFAULT '', surface TEXT DEFAULT '', + source TEXT DEFAULT 'mine', sg TEXT DEFAULT '', swatch TEXT DEFAULT '', + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS glaze_materials ( + id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, name TEXT NOT NULL, + pct REAL NOT NULL, addition INTEGER DEFAULT 0); + CREATE TABLE IF NOT EXISTS glaze_journal ( + id INTEGER PRIMARY KEY, glaze_id INTEGER NOT NULL, text TEXT NOT NULL, + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS firings ( + id INTEGER PRIMARY KEY, type TEXT DEFAULT 'glaze', cone TEXT DEFAULT '∆6', + schedule TEXT DEFAULT '', status TEXT DEFAULT 'firing', + result_notes TEXT DEFAULT '', + started TEXT DEFAULT (datetime('now','localtime')), unloaded TEXT); + CREATE TABLE IF NOT EXISTS firing_items ( + id INTEGER PRIMARY KEY, firing_id INTEGER NOT NULL, piece TEXT NOT NULL, + clay TEXT DEFAULT '', glaze TEXT DEFAULT '', rating TEXT DEFAULT '', note TEXT DEFAULT ''); + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'core line', + category TEXT DEFAULT '', price REAL DEFAULT 0, cost REAL DEFAULT 0, + qty INTEGER DEFAULT 0, status TEXT DEFAULT 'in progress', + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS sales ( + id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, qty INTEGER DEFAULT 1, + price REAL DEFAULT 0, cost REAL DEFAULT 0, + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY, entity TEXT NOT NULL, entity_id INTEGER NOT NULL, + filename TEXT NOT NULL, created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT DEFAULT 'online', + distance TEXT DEFAULT '', url TEXT DEFAULT '', notes TEXT DEFAULT '', + created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS price_entries ( + id INTEGER PRIMARY KEY, item TEXT NOT NULL, supplier_id INTEGER NOT NULL, + price REAL NOT NULL, unit TEXT DEFAULT '', shipping REAL DEFAULT 0, + note TEXT DEFAULT '', created TEXT DEFAULT (datetime('now','localtime'))); + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, entity TEXT DEFAULT '', entity_id INTEGER DEFAULT 0, + stored TEXT NOT NULL, name TEXT NOT NULL, tag TEXT DEFAULT '', + size INTEGER DEFAULT 0, created TEXT DEFAULT (datetime('now','localtime'))); +`); +const FILES = join(DATA, "files"); +mkdirSync(FILES, { recursive: true }); + +try { db.exec("ALTER TABLE todos ADD COLUMN parent_id INTEGER"); } catch { /* column exists */ } +try { db.exec("ALTER TABLE products ADD COLUMN notes TEXT DEFAULT ''"); } catch { /* column exists */ } +try { db.exec("ALTER TABLE suppliers ADD COLUMN search_url TEXT DEFAULT ''"); } catch { /* column exists */ } + +const all = (sql, ...p) => db.prepare(sql).all(...p); +const get = (sql, ...p) => db.prepare(sql).get(...p); +const run = (sql, ...p) => db.prepare(sql).run(...p); + +// ── the "plays well with" evidence table, built from unload ratings only ── +function compatibility(glazeName) { + return all( + `SELECT fi.clay, fi.rating, COUNT(*) n, GROUP_CONCAT(NULLIF(fi.note,''), ' · ') notes + FROM firing_items fi JOIN firings f ON f.id = fi.firing_id + WHERE fi.glaze = ? AND fi.rating != '' AND fi.clay != '' AND f.status = 'unloaded' + GROUP BY fi.clay, fi.rating ORDER BY fi.clay, n DESC`, glazeName); +} + +const routes = { + "GET /api/summary": () => ({ + todos: all("SELECT * FROM todos ORDER BY id DESC LIMIT 60"), + shopping: all("SELECT * FROM shopping ORDER BY done, id DESC LIMIT 12"), + notes: all("SELECT * FROM notes ORDER BY id DESC LIMIT 6"), + activeFiring: get("SELECT * FROM firings WHERE status='firing' ORDER BY id DESC LIMIT 1") ?? null, + lastUnload: get("SELECT * FROM firings WHERE status='unloaded' ORDER BY unloaded DESC LIMIT 1") ?? null, + }), + "POST /api/jot": (b) => { + const kind = b.kind || "note"; + const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean); + if (kind === "todo") { + // first line = the task, following lines = its subtasks + const r = run("INSERT INTO todos (text) VALUES (?)", lines[0]); + for (const sub of lines.slice(1)) run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", sub, r.lastInsertRowid); + return { ok: true }; + } + if (kind === "shopping") { + for (const l of lines) run("INSERT INTO shopping (text) VALUES (?)", l); + return { ok: true }; + } + return run("INSERT INTO notes (text, tags) VALUES (?, ?)", b.text, b.tags || (kind !== "note" ? kind : "")); + }, + "POST /api/todos/add": (b) => { + const r = run("INSERT INTO todos (text, parent_id) VALUES (?, ?)", b.text, b.parent_id ?? null); + return { id: Number(r.lastInsertRowid) }; + }, + "POST /api/todos/delete": (b) => { + run("DELETE FROM todos WHERE parent_id = ?", b.id); + return run("DELETE FROM todos WHERE id = ?", b.id); + }, + "POST /api/todos/toggle": (b) => run("UPDATE todos SET done = 1 - done WHERE id = ?", b.id), + "POST /api/shopping/toggle": (b) => run("UPDATE shopping SET done = 1 - done WHERE id = ?", b.id), + "GET /api/notes": () => all("SELECT * FROM notes ORDER BY id DESC"), + "GET /api/glazes": () => all("SELECT * FROM glazes ORDER BY name").map(g => ({ + ...g, materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id), + })), + "GET /api/glaze": (_, q) => { + const g = get("SELECT * FROM glazes WHERE id = ?", Number(q.get("id"))); + if (!g) return null; + return { ...g, + materials: all("SELECT * FROM glaze_materials WHERE glaze_id = ? ORDER BY addition, pct DESC", g.id), + journal: all("SELECT * FROM glaze_journal WHERE glaze_id = ? ORDER BY id DESC", g.id), + compatibility: compatibility(g.name), + photos: all("SELECT * FROM photos WHERE entity='glaze' AND entity_id = ?", g.id) }; + }, + "POST /api/glazes": (b) => { + const r = run( + "INSERT INTO glazes (name, kind, cone, atmosphere, surface, source, sg, swatch) VALUES (?,?,?,?,?,?,?,?)", + b.name, b.kind || "glaze", b.cone || "", b.atmosphere || "", b.surface || "", b.source || "mine", b.sg || "", b.swatch || ""); + for (const m of b.materials || []) + run("INSERT INTO glaze_materials (glaze_id, name, pct, addition) VALUES (?,?,?,?)", + r.lastInsertRowid, m.name, m.pct, m.addition ? 1 : 0); + return { id: Number(r.lastInsertRowid) }; + }, + "POST /api/glaze/journal": (b) => run("INSERT INTO glaze_journal (glaze_id, text) VALUES (?,?)", b.glaze_id, b.text), + "GET /api/firings": () => all("SELECT * FROM firings ORDER BY id DESC").map(f => ({ + ...f, items: all("SELECT * FROM firing_items WHERE firing_id = ?", f.id) })), + "POST /api/firings": (b) => { + const r = run("INSERT INTO firings (type, cone, schedule) VALUES (?,?,?)", b.type || "glaze", b.cone || "∆6", b.schedule || ""); + for (const it of b.items || []) + run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", r.lastInsertRowid, it.piece, it.clay || "", it.glaze || ""); + return { id: Number(r.lastInsertRowid) }; + }, + "POST /api/firing/items": (b) => run("INSERT INTO firing_items (firing_id, piece, clay, glaze) VALUES (?,?,?,?)", b.firing_id, b.piece, b.clay || "", b.glaze || ""), + "POST /api/firing/rate": (b) => run("UPDATE firing_items SET rating = ?, note = ? WHERE id = ?", b.rating, b.note || "", b.id), + "POST /api/firing/unload": (b) => run("UPDATE firings SET status='unloaded', unloaded=datetime('now','localtime'), result_notes=? WHERE id=?", b.result_notes || "", b.id), + "GET /api/products": () => ({ + products: all("SELECT * FROM products ORDER BY kind, category, name"), + money: { + revenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales").v, + cogs: get("SELECT COALESCE(SUM(qty*cost),0) v FROM sales").v, + monthRevenue: get("SELECT COALESCE(SUM(qty*price),0) v FROM sales WHERE created >= date('now','start of month')").v, + salesCount: get("SELECT COALESCE(SUM(qty),0) v FROM sales").v, + }, + }), + "POST /api/products": (b) => { + const r = run("INSERT INTO products (name, kind, category, price, cost, qty) VALUES (?,?,?,?,?,?)", + b.name, b.kind || "core line", b.category || "", Number(b.price) || 0, Number(b.cost) || 0, Number(b.qty) || 0); + return { id: Number(r.lastInsertRowid) }; + }, + "GET /api/product": (_, q) => { + const p = get("SELECT * FROM products WHERE id = ?", Number(q.get("id"))); + if (!p) return null; + return { ...p, + photos: all("SELECT * FROM photos WHERE entity='product' AND entity_id = ?", p.id), + files: all("SELECT * FROM files WHERE entity='product' AND entity_id = ? ORDER BY id DESC", p.id), + sales: all("SELECT * FROM sales WHERE product_id = ? ORDER BY id DESC LIMIT 10", p.id) }; + }, + "POST /api/products/notes": (b) => run("UPDATE products SET notes = ? WHERE id = ?", b.notes, b.id), + "GET /api/files": () => all(`SELECT f.*, p.name AS product_name FROM files f + LEFT JOIN products p ON f.entity = 'product' AND p.id = f.entity_id ORDER BY f.id DESC`), + "POST /api/files/delete": (b) => { + const f = get("SELECT * FROM files WHERE id = ?", b.id); + if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } run("DELETE FROM files WHERE id = ?", b.id); } + return { ok: true }; + }, + "GET /api/sourcing": () => ({ + suppliers: all("SELECT * FROM suppliers ORDER BY name"), + entries: all(`SELECT pe.*, s.name AS supplier, s.kind AS supplier_kind, s.distance + FROM price_entries pe JOIN suppliers s ON s.id = pe.supplier_id ORDER BY pe.item, pe.id DESC`), + }), + "POST /api/suppliers": (b) => { + const r = run("INSERT INTO suppliers (name, kind, distance, url, notes, search_url) VALUES (?,?,?,?,?,?)", + b.name, b.kind || "online", b.distance || "", b.url || "", b.notes || "", b.search_url || ""); + return { id: Number(r.lastInsertRowid) }; + }, + "POST /api/price-entries": (b) => run( + "INSERT INTO price_entries (item, supplier_id, price, unit, shipping, note) VALUES (?,?,?,?,?,?)", + b.item, b.supplier_id, Number(b.price) || 0, b.unit || "", Number(b.shipping) || 0, b.note || ""), + "POST /api/products/qty": (b) => run("UPDATE products SET qty = MAX(0, qty + ?) WHERE id = ?", b.delta, b.id), + "POST /api/products/status": (b) => run("UPDATE products SET status = ? WHERE id = ?", b.status, b.id), + "POST /api/sales": (b) => { + const p = get("SELECT * FROM products WHERE id = ?", b.product_id); + if (!p) return { error: "no such product" }; + run("INSERT INTO sales (product_id, qty, price, cost) VALUES (?,?,?,?)", p.id, b.qty || 1, b.price ?? p.price, p.cost); + if (p.kind === "core line" || p.kind === "tiles") run("UPDATE products SET qty = MAX(0, qty - ?) WHERE id = ?", b.qty || 1, p.id); + else run("UPDATE products SET status = 'sold' WHERE id = ?", p.id); + return { ok: true }; + }, +}; + +const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" }; + +createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + const send = (code, body, type = "application/json") => { + res.writeHead(code, { "content-type": type }); + res.end(type === "application/json" ? JSON.stringify(body) : body); + }; + try { + // photo upload: POST /api/photos?entity=note&id=3 (raw image body) + if (req.method === "POST" && url.pathname === "/api/photos") { + const chunks = []; + for await (const c of req) chunks.push(c); + const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase(); + const filename = randomUUID() + ext; + writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks)); + run("INSERT INTO photos (entity, entity_id, filename) VALUES (?,?,?)", + url.searchParams.get("entity") || "note", Number(url.searchParams.get("id") || 0), filename); + return send(200, { ok: true, filename }); + } + // file upload: POST /api/files?name=mold.stl&tag=master+molds[&entity=product&id=3] + if (req.method === "POST" && url.pathname === "/api/files") { + const chunks = []; + for await (const c of req) chunks.push(c); + const buf = Buffer.concat(chunks); + const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_"); + const stored = randomUUID() + extname(orig).toLowerCase(); + writeFileSync(join(FILES, stored), buf); + run("INSERT INTO files (entity, entity_id, stored, name, tag, size) VALUES (?,?,?,?,?,?)", + url.searchParams.get("entity") || "", Number(url.searchParams.get("id") || 0), + stored, orig, url.searchParams.get("tag") || "", buf.length); + return send(200, { ok: true }); + } + if (url.pathname.startsWith("/file/")) { + const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, ""); + const f = get("SELECT * FROM files WHERE stored = ?", stored); + if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" }); + res.writeHead(200, { + "content-type": MIME[extname(stored)] || "application/octet-stream", + "content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"`, + }); + return res.end(readFileSync(join(FILES, stored))); + } + if (url.pathname.startsWith("/photos/")) { + const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, "")); + if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream"); + return send(404, { error: "not found" }); + } + const handler = routes[`${req.method} ${url.pathname}`]; + if (handler) { + let body = {}; + if (req.method === "POST") { + const chunks = []; + for await (const c of req) chunks.push(c); + body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + } + return send(200, handler(body, url.searchParams) ?? { ok: true }); + } + // static app + const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, ""); + const path = join(ROOT, "public", file); + if (existsSync(path)) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain"); + send(404, { error: "not found" }); + } catch (e) { + send(500, { error: String(e.message || e) }); + } +}).listen(6161, "127.0.0.1", () => { + console.log("🌙 Studio Notebook running at http://localhost:6161"); +});