Studio Notebook: vision, Phase 1 designs, and working prototype
- 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 <noreply@anthropic.com>
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "studio-notebook",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["prototype/server.mjs"],
|
||||
"port": 6161
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
prototype/data/
|
||||
.claude/settings.local.json
|
||||
.DS_Store
|
||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
@@ -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 `<slug>.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.
|
||||
6
Start Studio Notebook.command
Executable file
6
Start Studio Notebook.command
Executable file
@@ -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
|
||||
170
VISION.md
Normal file
170
VISION.md
Normal file
@@ -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.
|
||||
295
design/phase1-glaze-firing-screens.html
Normal file
295
design/phase1-glaze-firing-screens.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Glaze & Firing screens — Phase 1 sketch</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; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; padding: 0 20px 60px; }
|
||||
header { padding: 30px 0 0; }
|
||||
.banner { background: var(--butter); border: 1.5px dashed var(--blush-deep); border-radius: 14px; padding: 10px 16px; font-size: 13px; color: #8d6a52; margin-bottom: 22px; }
|
||||
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 26px; font-weight: 600; color: var(--plum-deep); }
|
||||
.sub { color: #9c8698; font-size: 14.5px; margin-top: 3px; font-style: italic; font-family: Georgia, serif; }
|
||||
|
||||
.tab-radio { position: absolute; opacity: 0; pointer-events: none; }
|
||||
nav { display: flex; gap: 8px; margin: 22px 0 26px; flex-wrap: wrap; }
|
||||
nav label { border: 1.5px solid var(--line); background: var(--card); color: #a08fa0; padding: 9px 16px; border-radius: 999px; font-size: 14px; cursor: pointer; font-weight: 600; transition: all .15s; }
|
||||
nav label:hover { border-color: var(--blush-deep); color: var(--plum-deep); }
|
||||
.panel { display: none; }
|
||||
#s-glaze:checked ~ .wrap nav label[for="s-glaze"],
|
||||
#s-newglaze:checked ~ .wrap nav label[for="s-newglaze"],
|
||||
#s-firing:checked ~ .wrap nav label[for="s-firing"],
|
||||
#s-results:checked ~ .wrap nav label[for="s-results"] { background: var(--plum); border-color: var(--plum); color: #fff; }
|
||||
#s-glaze:checked ~ .wrap #glaze, #s-newglaze:checked ~ .wrap #newglaze,
|
||||
#s-firing:checked ~ .wrap #firing, #s-results:checked ~ .wrap #results { display: block; }
|
||||
|
||||
.grid { display: grid; gap: 14px; } .g2 { grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 640px){ .g2 { 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; }
|
||||
.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; }
|
||||
.pill.ghost { background: transparent; border: 1.5px dashed var(--blush-deep); }
|
||||
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); }
|
||||
td:last-child { color: #9c8698; font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border: none; }
|
||||
.hero { display: flex; gap: 18px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.hero .swatch { width: 110px; height: 110px; border-radius: 16px; background: linear-gradient(150deg,#b8cec4,#8fb0a3); border: 1px solid rgba(0,0,0,0.05); flex-shrink: 0; }
|
||||
.hero .pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; font-size: 14px; }
|
||||
.photos { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.ph { width: 64px; height: 64px; border-radius: 10px; background: linear-gradient(135deg, var(--blush), var(--moon)); display: flex; align-items: center; justify-content: center; font-size: 22px; }
|
||||
.ph.add { background: transparent; border: 1.5px dashed var(--blush-deep); color: var(--blush-deep); font-size: 26px; }
|
||||
.mt { margin-top: 14px; }
|
||||
.matrix td:first-child { font-weight: 600; }
|
||||
.yes { color: #5c7157; font-weight: 700; } .no { color: #b3556b; font-weight: 700; } .unk { color: #b1a0ae; }
|
||||
.field { margin-bottom: 13px; }
|
||||
.field label { display: block; font-size: 12.5px; font-weight: 700; color: var(--blush-deep); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px; }
|
||||
.field input[type=text], .field textarea { width: 100%; border: 1.5px solid var(--line); border-radius: 12px; padding: 11px 14px; font-size: 14.5px; background: var(--bg); color: var(--ink); font-family: inherit; }
|
||||
.choices { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.choice { font-size: 13px; font-weight: 650; padding: 6px 14px; border-radius: 999px; background: var(--card); border: 1.5px solid var(--line); color: #a08fa0; cursor: pointer; }
|
||||
.choice.on { background: var(--plum); border-color: var(--plum); color: #fff; }
|
||||
.btn { display: inline-block; border: none; border-radius: 12px; padding: 11px 22px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; }
|
||||
.btn.primary { background: var(--plum); color: #fff; }
|
||||
.btn.soft { background: var(--blush); color: var(--plum-deep); }
|
||||
.steps { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.step { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); }
|
||||
.step.dim { background: transparent; color: #b1a0ae; border: 1px dashed var(--line); }
|
||||
.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; }
|
||||
.verdict { display: flex; gap: 6px; }
|
||||
.verdict span { font-size: 16px; padding: 5px 10px; border-radius: 10px; background: var(--bg); border: 1.5px solid var(--line); cursor: pointer; }
|
||||
.verdict span.on { background: var(--sage-soft); border-color: var(--sage); }
|
||||
.note-inline { font-size: 13px; color: #9c8698; font-style: italic; }
|
||||
.footnote { color: #b1a0ae; font-size: 13px; margin-top: 28px; text-align: center; font-style: italic; font-family: Georgia, serif; }
|
||||
.sparkle { background: var(--sage-soft); border-radius: 12px; padding: 10px 14px; font-size: 13.5px; color: #55624c; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input class="tab-radio" type="radio" name="tabs" id="s-glaze" checked>
|
||||
<input class="tab-radio" type="radio" name="tabs" id="s-newglaze">
|
||||
<input class="tab-radio" type="radio" name="tabs" id="s-firing">
|
||||
<input class="tab-radio" type="radio" name="tabs" id="s-results">
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="banner">✏️ Sketch round 3 — the deep screens. Four tabs = four different moments in studio life.</div>
|
||||
<h1>🌙 Glazes & Firings — the deep screens</h1>
|
||||
<div class="sub">a glaze's home page · adding a new one · loading the kiln · unload day</div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<label for="s-glaze">🧪 A glaze's page</label>
|
||||
<label for="s-newglaze">+ New glaze</label>
|
||||
<label for="s-firing">🔥 Start a firing</label>
|
||||
<label for="s-results">✨ Unload day</label>
|
||||
</nav>
|
||||
|
||||
<!-- ============ GLAZE DETAIL ============ -->
|
||||
<section class="panel" id="glaze">
|
||||
<div class="card">
|
||||
<div class="hero">
|
||||
<div class="swatch"></div>
|
||||
<div>
|
||||
<h4>Sea-glass Celadon</h4>
|
||||
<div class="meta">∆6 oxidation · glossy · translucent · dip 3s ×3</div>
|
||||
<div class="pills">
|
||||
<span class="pill sage">7 firings logged</span>
|
||||
<span class="pill">S.G. 1.45</span>
|
||||
<span class="pill butter">from my old notebook</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div>
|
||||
<h3>Recipe <span class="note-inline">· tap a number to edit</span></h3>
|
||||
<table>
|
||||
<tr><th>Material</th><th>%</th><th>2,000 g batch</th></tr>
|
||||
<tr><td>Custer Feldspar</td><td>38</td><td>760 g</td></tr>
|
||||
<tr><td>Silica</td><td>28</td><td>560 g</td></tr>
|
||||
<tr><td>Whiting</td><td>18</td><td>360 g</td></tr>
|
||||
<tr><td>EPK Kaolin</td><td>16</td><td>320 g</td></tr>
|
||||
<tr><td>+ Copper Carbonate</td><td>0.5</td><td>10 g</td></tr>
|
||||
<tr><td>+ Bentonite</td><td>2</td><td>40 g</td></tr>
|
||||
</table>
|
||||
<div class="mt"><span class="pill ghost">⚖️ scale batch: 500g · 1kg · 2kg · 5 gal</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Plays well with…</h3>
|
||||
<table class="matrix">
|
||||
<tr><th>Clay body</th><th>Verdict</th><th>Evidence</th></tr>
|
||||
<tr><td>B-Mix</td><td class="yes">♥ lovely</td><td>4 firings</td></tr>
|
||||
<tr><td>Speckled Brownstone</td><td class="yes">✓ good</td><td>2 firings</td></tr>
|
||||
<tr><td>Porcelain</td><td class="unk">? untested</td><td>—</td></tr>
|
||||
<tr><td>Dark stoneware</td><td class="no">✗ pinholes</td><td>1 firing</td></tr>
|
||||
</table>
|
||||
<div class="sparkle">🌱 This table grows by itself — every unload day where you rate a piece adds a row of evidence. You never fill it in directly.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt">
|
||||
<h3>Journal</h3>
|
||||
<div class="result">📒 <b>Jul 12</b> — thin = washes out. 3 dips or S.G. 1.45. Pools gorgeously in carved lines.</div>
|
||||
<div class="result mt">📒 <b>Jun 30</b> — variation #2 with +1% copper: too dark, muddy. Staying with original.</div>
|
||||
</div>
|
||||
<div class="mt">
|
||||
<h3>Test tiles & pieces</h3>
|
||||
<div class="photos"><div class="ph">🧪</div><div class="ph">🏺</div><div class="ph">🫖</div><div class="ph">🥣</div><div class="ph add">+</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt">
|
||||
<h3>Where it lands — the chemistry map <span class="note-inline">· the Stull chart, like Glazy's</span></h3>
|
||||
<svg viewBox="0 0 380 310" role="img" aria-label="Stull chart: Sea-glass Celadon plotted in the bright gloss region" style="width:100%;max-width:560px;display:block">
|
||||
<!-- regions (schematic; the real app computes these per cone) -->
|
||||
<polygon points="40,20 340,20 340,60 90,80 40,90" fill="#f0ece6"/>
|
||||
<polygon points="40,90 90,80 110,260 40,260" fill="#f3dbe4" opacity="0.75"/>
|
||||
<polygon points="90,80 340,60 340,120 150,260 110,260" fill="#e4ecdf"/>
|
||||
<polygon points="340,120 340,180 200,260 150,260" fill="#f0e6f4"/>
|
||||
<polygon points="340,180 340,260 200,260" fill="#f7ecd8"/>
|
||||
<!-- region labels -->
|
||||
<text x="185" y="45" font-size="11" fill="#a08fa0" text-anchor="middle" font-style="italic">under-fired</text>
|
||||
<text x="63" y="170" font-size="11" fill="#b3849a" text-anchor="middle" font-style="italic" transform="rotate(-78 63 170)">crazed</text>
|
||||
<text x="205" y="115" font-size="12" fill="#5c7157" text-anchor="middle" font-weight="600" font-style="italic">bright gloss</text>
|
||||
<text x="252" y="212" font-size="11" fill="#8d6b94" text-anchor="middle" font-style="italic">semi-matte</text>
|
||||
<text x="303" y="240" font-size="11" fill="#8d6a52" text-anchor="middle" font-style="italic">matte</text>
|
||||
<!-- axes -->
|
||||
<line x1="40" y1="20" x2="40" y2="260" stroke="#d8c9ce" stroke-width="1.5"/>
|
||||
<line x1="40" y1="260" x2="340" y2="260" stroke="#d8c9ce" stroke-width="1.5"/>
|
||||
<text x="190" y="290" font-size="11.5" fill="#9c8698" text-anchor="middle">alumina (Al₂O₃) →</text>
|
||||
<text x="16" y="140" font-size="11.5" fill="#9c8698" text-anchor="middle" transform="rotate(-90 16 140)">silica (SiO₂) →</text>
|
||||
<text x="90" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.2</text>
|
||||
<text x="190" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.4</text>
|
||||
<text x="290" y="274" font-size="10" fill="#b1a0ae" text-anchor="middle">0.6</text>
|
||||
<text x="32" y="216" font-size="10" fill="#b1a0ae" text-anchor="end">2</text>
|
||||
<text x="32" y="168" font-size="10" fill="#b1a0ae" text-anchor="end">3</text>
|
||||
<text x="32" y="120" font-size="10" fill="#b1a0ae" text-anchor="end">4</text>
|
||||
<text x="32" y="72" font-size="10" fill="#b1a0ae" text-anchor="end">5</text>
|
||||
<!-- other glazes, faded -->
|
||||
<circle cx="250" cy="195" r="5" fill="#c9b8d4"/>
|
||||
<text x="250" y="184" font-size="9.5" fill="#b1a0ae" text-anchor="middle">Toasted Amber</text>
|
||||
<circle cx="290" cy="217" r="5" fill="#c9b8d4"/>
|
||||
<text x="296" y="232" font-size="9.5" fill="#b1a0ae" text-anchor="middle">Oat Matte</text>
|
||||
<!-- this glaze -->
|
||||
<circle cx="165" cy="145" r="8" fill="#6e4f78" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<text x="165" y="132" font-size="11" fill="#6e4f78" text-anchor="middle" font-weight="700">Sea-glass Celadon</text>
|
||||
</svg>
|
||||
<div class="sparkle">🧮 Computed from the recipe automatically — no data entry. The dot says <i>why</i> 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.)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ NEW GLAZE ============ -->
|
||||
<section class="panel" id="newglaze">
|
||||
<div class="card">
|
||||
<h4>New glaze</h4>
|
||||
<div class="meta" style="margin-bottom:16px">Only the name is required — everything else can arrive later.</div>
|
||||
<div class="field"><label>Name</label><input type="text" placeholder="e.g. Moonrise Blue"></div>
|
||||
<div class="grid g2">
|
||||
<div class="field"><label>Cone</label>
|
||||
<div class="choices"><span class="choice">∆04</span><span class="choice on">∆6</span><span class="choice">∆10</span><span class="choice">other</span></div>
|
||||
</div>
|
||||
<div class="field"><label>Atmosphere</label>
|
||||
<div class="choices"><span class="choice on">oxidation</span><span class="choice">reduction</span></div>
|
||||
</div>
|
||||
<div class="field"><label>Surface</label>
|
||||
<div class="choices"><span class="choice">glossy</span><span class="choice on">satin</span><span class="choice">matte</span></div>
|
||||
</div>
|
||||
<div class="field"><label>Where's it from?</label>
|
||||
<div class="choices"><span class="choice on">mine</span><span class="choice">Glazy</span><span class="choice">book / friend</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Recipe</label>
|
||||
<table>
|
||||
<tr><td>Nepheline Syenite</td><td>45</td></tr>
|
||||
<tr><td>Silica</td><td>25</td></tr>
|
||||
<tr><td><span class="note-inline">+ add material (autocompletes from your pantry)</span></td><td></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="field"><label>First note</label><textarea rows="2" placeholder="Why this glaze? What are you hoping for?"></textarea></div>
|
||||
<button class="btn primary">Save glaze</button>
|
||||
<button class="btn soft">Save & mix a test batch</button>
|
||||
<div class="sparkle">💡 Later (Phase 4): paste a Glazy link and the recipe fills itself in.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ START A FIRING ============ -->
|
||||
<section class="panel" id="firing">
|
||||
<div class="card">
|
||||
<div class="steps">
|
||||
<span class="step">1 · plan</span><span class="step">2 · load</span><span class="step dim">3 · fire</span><span class="step dim">4 · unload</span>
|
||||
<span class="note-inline">← a trail, not a form. Skip everything.</span>
|
||||
</div>
|
||||
<h4>Firing #5</h4>
|
||||
<div class="result" style="margin-top:10px">🔥 <b>Fill-the-kiln-and-go mode:</b> tap <i>start firing</i> 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.</div>
|
||||
<div class="grid g2 mt">
|
||||
<div class="field"><label>Type</label>
|
||||
<div class="choices"><span class="choice">bisque ∆04</span><span class="choice on">glaze ∆6</span></div>
|
||||
</div>
|
||||
<div class="field"><label>Schedule</label>
|
||||
<div class="choices"><span class="choice on">slow ramp + 15 min hold</span><span class="choice">standard</span><span class="choice">custom…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>What's going in <span class="note-inline">· piece + clay + glaze — this is the magic combo</span></h3>
|
||||
<div class="loadrow"><span>🏺 6 carved mugs</span><span class="meta">B-Mix · Sea-glass Celadon</span></div>
|
||||
<div class="loadrow"><span>🥣 2 serving bowls</span><span class="meta">Speckled Brownstone · Toasted Amber</span></div>
|
||||
<div class="loadrow"><span>🧪 Test tiles ×4</span><span class="meta">Porcelain · Sea-glass Celadon <span class="pill butter">first porcelain test!</span></span></div>
|
||||
<div class="mt"><span class="pill ghost">+ add from "in the studio" list</span></div>
|
||||
<div class="mt">
|
||||
<h3>Kiln photo before closing</h3>
|
||||
<div class="photos"><div class="ph">📷</div><div class="ph add">+</div></div>
|
||||
<div class="note-inline mt">Future-you, on unload day, will be very glad this exists.</div>
|
||||
</div>
|
||||
<div class="mt">
|
||||
<button class="btn primary">Kiln is loaded — start firing 🔥</button>
|
||||
</div>
|
||||
<div class="sparkle">🔌 Phase 5: this button actually talks to Tony's kiln controller — ramp status and cone-offs show up right here.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ UNLOAD DAY ============ -->
|
||||
<section class="panel" id="results">
|
||||
<div class="card">
|
||||
<div class="steps">
|
||||
<span class="step dim">1 · plan</span><span class="step dim">2 · load</span><span class="step dim">3 · fire</span><span class="step">4 · unload ✨</span>
|
||||
</div>
|
||||
<h4>Firing #5 — unload day!</h4>
|
||||
<div class="meta" style="margin-bottom:14px">Rate each thing as it comes out. One tap each — notes only where you want them.</div>
|
||||
<div class="loadrow">
|
||||
<span>🏺 Carved mugs <span class="meta">B-Mix · Celadon</span></span>
|
||||
<span class="verdict"><span class="on">😍</span><span>🙂</span><span>😕</span><span>💔</span></span>
|
||||
</div>
|
||||
<div class="loadrow">
|
||||
<span>🥣 Bowls <span class="meta">Brownstone · Amber</span></span>
|
||||
<span class="verdict"><span>😍</span><span class="on">🙂</span><span>😕</span><span>💔</span></span>
|
||||
</div>
|
||||
<div class="loadrow">
|
||||
<span>🧪 Tiles <span class="meta">Porcelain · Celadon</span></span>
|
||||
<span class="verdict"><span>😍</span><span>🙂</span><span class="on">😕</span><span>💔</span></span>
|
||||
</div>
|
||||
<div class="field mt"><label>Notes on the 😕</label><textarea rows="2" placeholder="Celadon on porcelain: washed out even at 3 dips. Maybe 4? Or S.G. up to 1.5.">Celadon on porcelain: washed out even at 3 dips. Maybe 4 dips? Or S.G. up to 1.5.</textarea></div>
|
||||
<div class="mt">
|
||||
<h3>Photos</h3>
|
||||
<div class="photos"><div class="ph">📷</div><div class="ph">📷</div><div class="ph">📷</div><div class="ph add">+</div></div>
|
||||
</div>
|
||||
<div class="mt"><button class="btn primary">Save unload ✨</button></div>
|
||||
<div class="sparkle">🌱 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.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footnote">🌙 round 3 · the deep screens · what would make unload day feel like opening presents?</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
251
design/phase1-studio-notebook-mockup.html
Normal file
251
design/phase1-studio-notebook-mockup.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Studio Notebook — Phase 1 sketch</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; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; padding: 0 20px 60px; }
|
||||
header { padding: 30px 0 0; }
|
||||
.banner { background: var(--butter); border: 1.5px dashed var(--blush-deep); border-radius: 14px; padding: 10px 16px; font-size: 13px; color: #8d6a52; margin-bottom: 22px; }
|
||||
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: 30px; font-weight: 600; letter-spacing: 0.2px; color: var(--plum-deep); }
|
||||
h1 .moon { font-style: normal; }
|
||||
.sub { color: #9c8698; font-size: 15px; margin-top: 3px; font-style: italic; font-family: Georgia, serif; }
|
||||
|
||||
/* CSS-only tabs — radio inputs, no scripts needed */
|
||||
.tab-radio { position: absolute; opacity: 0; pointer-events: none; }
|
||||
nav { display: flex; gap: 8px; margin: 24px 0 26px; flex-wrap: wrap; }
|
||||
nav label { border: 1.5px solid var(--line); background: var(--card); color: #a08fa0; padding: 9px 20px; border-radius: 999px; font-size: 14.5px; cursor: pointer; font-weight: 600; transition: all .15s; }
|
||||
nav label:hover { border-color: var(--blush-deep); color: var(--plum-deep); }
|
||||
.panel { display: none; }
|
||||
#t-today:checked ~ .wrap nav label[for="t-today"],
|
||||
#t-glazes:checked ~ .wrap nav label[for="t-glazes"],
|
||||
#t-firings:checked ~ .wrap nav label[for="t-firings"],
|
||||
#t-notes:checked ~ .wrap nav label[for="t-notes"] { background: var(--plum); border-color: var(--plum); color: #fff; }
|
||||
#t-today:checked ~ .wrap #today, #t-glazes:checked ~ .wrap #glazes,
|
||||
#t-firings:checked ~ .wrap #firings, #t-notes:checked ~ .wrap #notes { display: block; }
|
||||
|
||||
.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; }
|
||||
.big-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); }
|
||||
.big-capture .row1 { display: flex; gap: 10px; }
|
||||
.big-capture input[type=text] { flex: 1; border: 1.5px solid var(--line); border-radius: 12px; padding: 12px 15px; font-size: 15px; background: var(--bg); color: var(--ink); font-family: inherit; }
|
||||
.big-capture .cam, .big-capture .go { border: none; border-radius: 12px; padding: 0 18px; cursor: pointer; font-family: inherit; }
|
||||
.big-capture .cam { background: var(--blush); font-size: 18px; }
|
||||
.big-capture .go { background: var(--plum); color: #fff; font-size: 14.5px; font-weight: 700; }
|
||||
.chips { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.chips .hint { font-size: 12.5px; color: #b1a0ae; margin-right: 2px; }
|
||||
.chip { font-size: 12.5px; font-weight: 650; padding: 4px 12px; border-radius: 999px; background: var(--moon); color: var(--plum-deep); border: 1px solid transparent; cursor: pointer; }
|
||||
.chip:hover { border-color: var(--plum); }
|
||||
.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); }
|
||||
.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; }
|
||||
.swatch { height: 74px; border-radius: 12px; margin-bottom: 12px; border: 1px solid rgba(0,0,0,0.05); }
|
||||
.glaze h4, .fire h4, .note-card h4 { font-family: Georgia, serif; font-size: 16.5px; font-weight: 600; color: var(--plum-deep); }
|
||||
.meta { color: #9c8698; font-size: 13.5px; margin: 2px 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 8px; }
|
||||
td { padding: 6px 4px; border-bottom: 1px solid var(--line); }
|
||||
td:last-child { text-align: right; color: #9c8698; font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border: none; }
|
||||
.fire { margin-bottom: 14px; }
|
||||
.fire .load { margin-top: 10px; font-size: 14px; }
|
||||
.fire .load li { margin-left: 18px; padding: 2px 0; }
|
||||
.result { background: var(--moon); border-radius: 12px; padding: 10px 14px; margin-top: 12px; font-size: 14px; }
|
||||
.photos { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.ph { width: 64px; height: 64px; border-radius: 10px; background: linear-gradient(135deg, var(--blush), var(--moon)); display: flex; align-items: center; justify-content: center; font-size: 22px; }
|
||||
.note-card p { font-size: 14.5px; margin-top: 6px; }
|
||||
.note-card .tags { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.mt { margin-top: 14px; }
|
||||
.kiln { display: flex; align-items: center; gap: 12px; }
|
||||
.kiln .dot { width: 10px; height: 10px; border-radius: 50%; background: #cabfd0; }
|
||||
.footnote { color: #b1a0ae; font-size: 13px; margin-top: 28px; text-align: center; font-style: italic; font-family: Georgia, serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input class="tab-radio" type="radio" name="tabs" id="t-today" checked>
|
||||
<input class="tab-radio" type="radio" name="tabs" id="t-glazes">
|
||||
<input class="tab-radio" type="radio" name="tabs" id="t-firings">
|
||||
<input class="tab-radio" type="radio" name="tabs" id="t-notes">
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="banner">✏️ Still a <strong>sketch, not the app</strong> — pretend data, made to be reacted to. Tabs work now!</div>
|
||||
<h1><span class="moon">🌙</span> Studio Notebook</h1>
|
||||
<div class="sub">Good morning, Bonna — day 3 in the new studio</div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<label for="t-today">Today</label>
|
||||
<label for="t-glazes">Glazes</label>
|
||||
<label for="t-firings">Firing Log</label>
|
||||
<label for="t-notes">Notes</label>
|
||||
</nav>
|
||||
|
||||
<!-- TODAY -->
|
||||
<section class="panel" id="today">
|
||||
<div class="big-capture">
|
||||
<div class="row1">
|
||||
<input type="text" placeholder="Jot anything… a thought, a glaze idea, what's drying">
|
||||
<button class="cam" title="Snap a photo">📷</button>
|
||||
<button class="go">Save</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="hint">file it as…</span>
|
||||
<span class="chip">✨ just a note</span><span class="chip">🧪 glaze</span><span class="chip">🔥 firing</span><span class="chip">✓ to-do</span><span class="chip">🏺 piece</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div class="card">
|
||||
<h3>To-do</h3>
|
||||
<div class="todo"><input type="checkbox"><span>Wedge clay for mug run (B-Mix)</span></div>
|
||||
<div class="todo"><input type="checkbox"><span>Mix test batch — Celadon variation #2</span></div>
|
||||
<div class="todo"><input type="checkbox" checked><span class="done">Unload bisque firing</span></div>
|
||||
<div class="todo"><input type="checkbox"><span>Order silica — running low</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>In the studio</h3>
|
||||
<div class="todo"><span>🏺 6 mugs — <b>leather hard</b>, trim today</span></div>
|
||||
<div class="todo"><span>🥣 2 serving bowls — drying, 2 more days</span></div>
|
||||
<div class="todo"><span>🥄 Walnut spoon blanks — roughed out</span></div>
|
||||
<div class="mt"><span class="pill butter">next firing: bisque, ~Sat</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Kiln</h3>
|
||||
<div class="kiln"><div class="dot"></div><span>Idle — last fired 3 days ago (bisque, ∆04)</span></div>
|
||||
<div class="mt"><span class="pill sage">Tony's controller plugs in here, Phase 5</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Recent notes</h3>
|
||||
<div class="todo"><span>“Celadon breaks beautifully over carved texture…”</span></div>
|
||||
<div class="todo"><span>“New wheel position feels right by the window”</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Shopping list 🧺</h3>
|
||||
<div class="todo"><input type="checkbox"><span>Silica — 10 lb <span class="pill butter">pantry says low</span></span></div>
|
||||
<div class="todo"><input type="checkbox"><span>Business cards — reorder</span></div>
|
||||
<div class="todo"><input type="checkbox"><span>Tissue paper + moon stickers</span></div>
|
||||
<div class="todo"><input type="checkbox"><span>Trimming tool (the sharp little one)</span></div>
|
||||
<div class="mt"><span class="pill sage">one list for everything — materials, tools, packaging, paper goods</span></div>
|
||||
</div>
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<h3>How things land here</h3>
|
||||
<div class="todo"><span>✍️ Jot “order more wax resist” anywhere → chip it as <b>to-do</b> or say “shopping” → it files itself onto this list.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GLAZES -->
|
||||
<section class="panel" id="glazes">
|
||||
<div class="grid g3">
|
||||
<div class="card glaze">
|
||||
<div class="swatch" style="background:linear-gradient(150deg,#b8cec4,#8fb0a3)"></div>
|
||||
<h4>Sea-glass Celadon</h4>
|
||||
<div class="meta">∆6 ox · glossy · translucent</div>
|
||||
<span class="pill sage">tested · loves B-Mix</span>
|
||||
</div>
|
||||
<div class="card glaze">
|
||||
<div class="swatch" style="background:linear-gradient(150deg,#c98d5f,#8a5637)"></div>
|
||||
<h4>Toasted Amber</h4>
|
||||
<div class="meta">∆6 ox · satin · breaks rust</div>
|
||||
<span class="pill">2 tests · crawls on porcelain</span>
|
||||
</div>
|
||||
<div class="card glaze">
|
||||
<div class="swatch" style="background:linear-gradient(150deg,#ece7de,#cfc8ba)"></div>
|
||||
<h4>Oat Matte</h4>
|
||||
<div class="meta">∆6 ox · matte · opaque</div>
|
||||
<span class="pill butter">untested — from Glazy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt">
|
||||
<h3>Recipe — Sea-glass Celadon</h3>
|
||||
<table>
|
||||
<tr><td>Custer Feldspar</td><td>38</td></tr>
|
||||
<tr><td>Silica</td><td>28</td></tr>
|
||||
<tr><td>Whiting</td><td>18</td></tr>
|
||||
<tr><td>EPK Kaolin</td><td>16</td></tr>
|
||||
<tr><td>+ Copper Carbonate</td><td>0.5</td></tr>
|
||||
<tr><td>+ Bentonite</td><td>2</td></tr>
|
||||
</table>
|
||||
<div class="result">📒 <b>Notebook:</b> thin = washes out; 3 dips or S.G. 1.45. Gorgeous pooling in carved lines. <b>Works with:</b> B-Mix ✓, Speckled Brownstone ✓, Porcelain — untested.</div>
|
||||
<div class="photos"><div class="ph">🏺</div><div class="ph">🫖</div><div class="ph">🧪</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FIRING LOG -->
|
||||
<section class="panel" id="firings">
|
||||
<div class="card fire">
|
||||
<h4>Firing #4 — Glaze, ∆6 oxidation</h4>
|
||||
<div class="meta">Aug 2 · slow ramp, 15 min hold · 11 hrs</div>
|
||||
<ul class="load">
|
||||
<li>6 mugs — B-Mix — Sea-glass Celadon</li>
|
||||
<li>2 bowls — Speckled Brownstone — Toasted Amber</li>
|
||||
<li>4 test tiles — Oat Matte variations</li>
|
||||
</ul>
|
||||
<div class="result">✨ <b>Result:</b> Celadon perfect on carved mugs. Amber ran on bowl #2 — thinner next time or catch plates. Oat tiles: #3 is the keeper.</div>
|
||||
<div class="photos"><div class="ph">📷</div><div class="ph">📷</div></div>
|
||||
</div>
|
||||
<div class="card fire">
|
||||
<h4>Firing #3 — Bisque, ∆04</h4>
|
||||
<div class="meta">Jul 29 · standard bisque · 9 hrs</div>
|
||||
<ul class="load"><li>Full load — mugs, bowls, tiles</li></ul>
|
||||
<div class="result">All clean, no cracks. New kiln shelves level. 🎉</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NOTES -->
|
||||
<section class="panel" id="notes">
|
||||
<div class="big-capture">
|
||||
<div class="row1">
|
||||
<input type="text" placeholder="New note…">
|
||||
<button class="cam" title="Snap a photo">📷</button>
|
||||
<button class="go">Save</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="hint">tag it…</span>
|
||||
<span class="chip">glaze</span><span class="chip">wood</span><span class="chip">new studio</span><span class="chip">collection ideas</span><span class="chip">shows</span><span class="chip">+ new tag</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid g2">
|
||||
<div class="card note-card">
|
||||
<h4>Carving + celadon = the thing</h4>
|
||||
<p>The break over carved texture is exactly the look. Lean the whole first collection into carved surfaces. Chattering tool next?</p>
|
||||
<div class="photos"><div class="ph">🏺</div></div>
|
||||
<div class="tags"><span class="pill">glaze</span><span class="pill">collection ideas</span></div>
|
||||
</div>
|
||||
<div class="card note-card">
|
||||
<h4>Studio layout</h4>
|
||||
<p>Wheel by the window is right. Wedging table needs to move closer to clay storage — too many trips.</p>
|
||||
<div class="tags"><span class="pill">new studio</span></div>
|
||||
</div>
|
||||
<div class="card note-card">
|
||||
<h4>Spoon shapes</h4>
|
||||
<p>Walnut roughing went fast. Try a shorter, deeper bowl on the next blanks — more of a tasting spoon.</p>
|
||||
<div class="photos"><div class="ph">🥄</div><div class="ph">🪵</div></div>
|
||||
<div class="tags"><span class="pill">wood</span></div>
|
||||
</div>
|
||||
<div class="card note-card">
|
||||
<h4>Market idea — Fall Makers Fair</h4>
|
||||
<p>Deadline usually mid-September? Check fee. This becomes a real tracked thing in Phase 3.</p>
|
||||
<div class="tags"><span class="pill">shows</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footnote">🌙 Phase 1 sketch · tell me what feels right and what doesn't</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
73
prototype/README.md
Normal file
73
prototype/README.md
Normal file
@@ -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.
|
||||
612
prototype/public/app.js
vendored
Normal file
612
prototype/public/app.js
vendored
Normal file
@@ -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) => `<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);
|
||||
}
|
||||
|
||||
render();
|
||||
109
prototype/public/index.html
Normal file
109
prototype/public/index.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!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>
|
||||
</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 · Phase 1 prototype · your data lives safely on this Mac</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
282
prototype/server.mjs
Normal file
282
prototype/server.mjs
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user