From da63ee8f7c1cf00d1a85b3ebfff3d081ee53a888 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Thu, 27 Aug 2026 23:28:29 -0500 Subject: [PATCH] add metadata, inventory workflows, web client --- .gitignore | 2 + README.md | 25 +- api/openapi.yaml | 323 +++ api/tree-import.schema.json | 3 + .../java/app/boxmanifest/BoxManifestApi.kt | 20 +- .../app/boxmanifest/data/NodeRepository.kt | 12 +- .../java/app/boxmanifest/tree/TreeScreen.kt | 44 +- .../app/boxmanifest/tree/TreeViewModel.kt | 12 +- docs/PROJECT_PLAN.md | 18 +- docs/WEB_LABEL_SHEET_GENERATION.md | 75 + docs/design/DESKTOP_DESIGN_REFERENCE.md | 352 +++ server/cmd/box-manifest-server/main.go | 2 +- server/internal/httpapi/handler.go | 221 +- server/internal/httpapi/handler_test.go | 67 + server/internal/importer/service.go | 9 +- server/internal/tree/metadata.go | 319 +++ server/internal/tree/metadata_test.go | 94 + server/internal/tree/store.go | 298 ++- server/internal/tree/store_test.go | 92 +- web/app.js | 338 --- web/index.html | 101 +- web/package-lock.json | 1878 +++++++++++++++++ web/package.json | 27 + web/src/App.tsx | 719 +++++++ web/src/LabelSheetDialog.tsx | 204 ++ web/src/api.ts | 41 + web/src/icons.tsx | 20 + web/src/labelTemplates.ts | 69 + web/src/main.tsx | 14 + web/src/styles.css | 483 +++++ web/tsconfig.json | 20 + web/vite.config.ts | 13 + 32 files changed, 5397 insertions(+), 518 deletions(-) create mode 100644 docs/WEB_LABEL_SHEET_GENERATION.md create mode 100644 docs/design/DESKTOP_DESIGN_REFERENCE.md create mode 100644 server/internal/tree/metadata.go create mode 100644 server/internal/tree/metadata_test.go delete mode 100644 web/app.js create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/LabelSheetDialog.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/icons.tsx create mode 100644 web/src/labelTemplates.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 0c1c994..ea85465 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ local.properties /server/data/ /server/box-manifest-server +/web/node_modules/ +/web/dist/ diff --git a/README.md b/README.md index 64e96e5..2f74f9d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository currently contains: - A Go HTTP server backed by SQLite - A native Android client built with Kotlin and Jetpack Compose -- A deliberately barebones web client for development and API diagnostics +- A Vite, React, and TypeScript web client - An OpenAPI 3.1 contract The project is pre-release and has no compatibility guarantee yet. @@ -20,10 +20,10 @@ The project is pre-release and has no compatibility guarantee yet. - Single and bulk node creation - Atomic single and multi-node moves - Empty-only deletion -- Local hierarchy search and breadcrumbs on Android +- Hierarchy search and breadcrumbs on Android and web - JSON tree-import preview and atomic commit - Stable six-character lookup codes -- QR-label preview, sharing, and scanning on Android +- QR-label preview/export on both clients, with scanning and visual Find on Android See [the universal-tree decision](docs/decisions/0001-universal-node-tree.md) for the core model and [the project plan](docs/PROJECT_PLAN.md) for current @@ -38,8 +38,21 @@ cd server go run ./cmd/box-manifest-server ``` -Open `http://localhost:8080` for the diagnostic web client. SQLite data is -stored at `server/data/box-manifest.db` by default and is ignored by Git. +SQLite data is stored at `server/data/box-manifest.db` by default and is +ignored by Git. + +Run the web development server in a separate terminal. It proxies API requests +to the Go server and serves the client at `http://localhost:5173`: + +```bash +cd web +npm install +npm run dev +``` + +For a production-style local build, run `npm run build` before starting the Go +server. The server serves the generated `web/dist` directory at +`http://localhost:8080`. Available options: @@ -47,7 +60,7 @@ Available options: go run ./cmd/box-manifest-server \ -address :9000 \ -database /path/to/box-manifest.db \ - -web ../web + -web ../web/dist ``` Run server tests with: diff --git a/api/openapi.yaml b/api/openapi.yaml index b1c7a79..ddc341a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -8,6 +8,116 @@ info: servers: - url: / paths: + /api/node-types: + get: + operationId: listNodeTypes + summary: List user-defined node types + responses: + "200": + description: Node types ordered by name + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/NodeType" } + default: { $ref: "#/components/responses/Error" } + post: + operationId: createNodeType + summary: Create a node type + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NodeTypeWrite" } + responses: + "201": + description: Node type created + content: + application/json: + schema: { $ref: "#/components/schemas/NodeType" } + default: { $ref: "#/components/responses/Error" } + /api/node-types/{typeId}: + parameters: + - name: typeId + in: path + required: true + schema: { type: string } + put: + operationId: updateNodeType + summary: Replace a node type + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NodeTypeWrite" } + responses: + "200": + description: Node type updated + content: + application/json: + schema: { $ref: "#/components/schemas/NodeType" } + default: { $ref: "#/components/responses/Error" } + delete: + operationId: deleteNodeType + summary: Delete a type and leave assigned nodes untyped + responses: + "204": { description: Node type deleted } + default: { $ref: "#/components/responses/Error" } + /api/tags: + get: + operationId: listTags + summary: List tags + responses: + "200": + description: Tags ordered by name + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Tag" } + default: { $ref: "#/components/responses/Error" } + post: + operationId: createTag + summary: Create a tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "201": + description: Tag created + content: + application/json: + schema: { $ref: "#/components/schemas/Tag" } + default: { $ref: "#/components/responses/Error" } + /api/tags/{tagId}: + parameters: + - name: tagId + in: path + required: true + schema: { type: string } + put: + operationId: updateTag + summary: Replace a tag + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TagWrite" } + responses: + "200": + description: Tag updated + content: + application/json: + schema: { $ref: "#/components/schemas/Tag" } + default: { $ref: "#/components/responses/Error" } + delete: + operationId: deleteTag + summary: Delete a tag and remove its node assignments + responses: + "204": { description: Tag deleted } + default: { $ref: "#/components/responses/Error" } /api/tree: get: operationId: getTree @@ -132,6 +242,40 @@ paths: description: All selected nodes deleted default: $ref: "#/components/responses/Error" + /api/nodes/bulk/combine: + post: + operationId: combineNodes + summary: Combine empty sibling nodes into one quantity-tracked node + description: >- + The retained node keeps its UUID and lookup code. Other selected nodes + and their lookup identities are deleted atomically. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CombineNodesRequest" + responses: + "200": + description: Combined quantity-tracked node + content: + application/json: + schema: + $ref: "#/components/schemas/Node" + default: + $ref: "#/components/responses/Error" + /api/nodes/bulk/classification: + post: + operationId: classifyNodesBulk + summary: Atomically assign a type and add or remove tags on multiple nodes + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BulkClassificationRequest" } + responses: + "204": { description: Classification updated } + default: { $ref: "#/components/responses/Error" } /api/nodes/{nodeId}: parameters: - $ref: "#/components/parameters/NodeId" @@ -227,6 +371,48 @@ paths: $ref: "#/components/schemas/Error" default: $ref: "#/components/responses/Error" + /api/nodes/{nodeId}/individualize: + parameters: + - $ref: "#/components/parameters/NodeId" + post: + operationId: individualizeNode + summary: Turn an empty quantity node into individually tracked siblings + description: >- + The original node becomes the first unit and retains its UUID and lookup + code. The supplied names must match the existing quantity. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IndividualizeNodeRequest" + responses: + "200": + description: Individually tracked nodes in unit order + content: + application/json: + schema: + $ref: "#/components/schemas/CreateNodesBulkResponse" + default: + $ref: "#/components/responses/Error" + /api/nodes/{nodeId}/classification: + parameters: + - $ref: "#/components/parameters/NodeId" + put: + operationId: classifyNode + summary: Replace a node's optional type and complete tag set + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ClassificationRequest" } + responses: + "200": + description: Classified node + content: + application/json: + schema: { $ref: "#/components/schemas/Node" } + default: { $ref: "#/components/responses/Error" } components: parameters: NodeId: @@ -251,7 +437,10 @@ components: - lookupCode - parentId - name + - description - quantity + - typeId + - tagIds properties: id: type: string @@ -267,11 +456,21 @@ components: name: type: string minLength: 1 + description: + type: + - string + - "null" quantity: type: - integer - "null" minimum: 0 + typeId: + type: [string, "null"] + tagIds: + type: array + uniqueItems: true + items: { type: string } ImportNode: type: object additionalProperties: false @@ -281,6 +480,8 @@ components: name: type: string minLength: 1 + description: + type: string quantity: type: - integer @@ -370,7 +571,10 @@ components: - lookupCode - parentId - name + - description - quantity + - typeId + - tagIds - children properties: id: @@ -386,11 +590,21 @@ components: name: type: string minLength: 1 + description: + type: + - string + - "null" quantity: type: - integer - "null" minimum: 0 + typeId: + type: [string, "null"] + tagIds: + type: array + uniqueItems: true + items: { type: string } children: type: array items: @@ -407,6 +621,10 @@ components: name: type: string minLength: 1 + description: + type: + - string + - "null" quantity: type: - integer @@ -427,6 +645,10 @@ components: items: type: string minLength: 1 + description: + type: + - string + - "null" CreateNodesBulkResponse: type: object additionalProperties: false @@ -481,6 +703,39 @@ components: uniqueItems: true items: type: string + IndividualizeNodeRequest: + type: object + additionalProperties: false + required: + - names + properties: + names: + type: array + minItems: 2 + maxItems: 1000 + items: + type: string + minLength: 1 + CombineNodesRequest: + type: object + additionalProperties: false + required: + - nodeIds + - retainedNodeId + - name + properties: + nodeIds: + type: array + minItems: 2 + uniqueItems: true + items: + type: string + retainedNodeId: + type: string + description: A member of nodeIds whose UUID and lookup code survive. + name: + type: string + minLength: 1 UpdateNodeRequest: type: object additionalProperties: false @@ -489,11 +744,79 @@ components: name: type: string minLength: 1 + description: + type: + - string + - "null" quantity: type: - integer - "null" minimum: 0 + NodeType: + type: object + additionalProperties: false + required: [id, name, description, iconKey, color] + properties: + id: { type: string } + name: { type: string, minLength: 1 } + description: { type: [string, "null"] } + iconKey: { type: [string, "null"] } + color: { type: [string, "null"] } + NodeTypeWrite: + type: object + additionalProperties: false + required: [name] + properties: + name: { type: string, minLength: 1 } + description: { type: [string, "null"] } + iconKey: { type: [string, "null"] } + color: { type: [string, "null"] } + Tag: + type: object + additionalProperties: false + required: [id, name, color] + properties: + id: { type: string } + name: { type: string, minLength: 1 } + color: { type: [string, "null"] } + TagWrite: + type: object + additionalProperties: false + required: [name] + properties: + name: { type: string, minLength: 1 } + color: { type: [string, "null"] } + ClassificationRequest: + type: object + additionalProperties: false + required: [typeId, tagIds] + properties: + typeId: { type: [string, "null"] } + tagIds: + type: array + uniqueItems: true + items: { type: string } + BulkClassificationRequest: + type: object + additionalProperties: false + required: [nodeIds, setType, typeId, addTagIds, removeTagIds] + properties: + nodeIds: + type: array + minItems: 1 + uniqueItems: true + items: { type: string } + setType: { type: boolean } + typeId: { type: [string, "null"] } + addTagIds: + type: array + uniqueItems: true + items: { type: string } + removeTagIds: + type: array + uniqueItems: true + items: { type: string } MoveNodeRequest: type: object additionalProperties: false diff --git a/api/tree-import.schema.json b/api/tree-import.schema.json index decb992..a97e998 100644 --- a/api/tree-import.schema.json +++ b/api/tree-import.schema.json @@ -23,6 +23,9 @@ "type": "string", "minLength": 1 }, + "description": { + "type": "string" + }, "quantity": { "type": ["integer", "null"], "minimum": 0 diff --git a/app/src/main/java/app/boxmanifest/BoxManifestApi.kt b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt index a42e8bf..83f6333 100644 --- a/app/src/main/java/app/boxmanifest/BoxManifestApi.kt +++ b/app/src/main/java/app/boxmanifest/BoxManifestApi.kt @@ -13,10 +13,14 @@ data class ApiNode( val quantity: Long?, val children: List = emptyList(), val lookupCode: String = "", + val description: String? = null, + val typeId: String? = null, + val tagIds: List = emptyList(), ) data class ImportNode( val name: String, + val description: String?, val quantity: Long?, val children: List, ) @@ -42,21 +46,22 @@ class BoxManifestApi( suspend fun getTree(): ApiNode = request("GET", "/api/tree").toNode() - suspend fun createNode(parentId: String?, name: String, quantity: Long?): ApiNode { + suspend fun createNode(parentId: String?, name: String, description: String?, quantity: Long?): ApiNode { val body = JSONObject() .put("name", name) + .put("description", description ?: JSONObject.NULL) .put("quantity", quantity ?: JSONObject.NULL) if (parentId != null) body.put("parentId", parentId) return request("POST", "/api/nodes", body).toNode() } - suspend fun createNodes(parentId: String, names: List): List { + suspend fun createNodes(parentId: String, names: List, description: String?): List { val values = org.json.JSONArray() names.forEach(values::put) val response = request( "POST", "/api/nodes/bulk", - JSONObject().put("parentId", parentId).put("names", values), + JSONObject().put("parentId", parentId).put("names", values).put("description", description ?: JSONObject.NULL), ) val nodes = response.getJSONArray("nodes") return List(nodes.length()) { index -> nodes.getJSONObject(index).toNode() } @@ -89,12 +94,13 @@ class BoxManifestApi( ) } - suspend fun updateNode(id: String, name: String, quantity: Long?): ApiNode = + suspend fun updateNode(id: String, name: String, description: String?, quantity: Long?): ApiNode = request( "PATCH", "/api/nodes/$id", JSONObject() .put("name", name) + .put("description", description ?: JSONObject.NULL) .put("quantity", quantity ?: JSONObject.NULL), ).toNode() @@ -180,7 +186,12 @@ private fun JSONObject.toNode(): ApiNode { lookupCode = getString("lookupCode"), parentId = if (isNull("parentId")) null else getString("parentId"), name = getString("name"), + description = if (isNull("description")) null else getString("description"), quantity = if (isNull("quantity")) null else getLong("quantity"), + typeId = if (isNull("typeId")) null else getString("typeId"), + tagIds = optJSONArray("tagIds")?.let { values -> + List(values.length()) { index -> values.getString(index) } + } ?: emptyList(), children = children, ) } @@ -189,6 +200,7 @@ private fun JSONObject.toImportNode(): ImportNode { val childrenJson = optJSONArray("children") return ImportNode( name = getString("name"), + description = if (isNull("description")) null else getString("description"), quantity = if (isNull("quantity")) null else getLong("quantity"), children = if (childrenJson == null) emptyList() else { List(childrenJson.length()) { childrenJson.getJSONObject(it).toImportNode() } diff --git a/app/src/main/java/app/boxmanifest/data/NodeRepository.kt b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt index 2ccc05d..c09d8e4 100644 --- a/app/src/main/java/app/boxmanifest/data/NodeRepository.kt +++ b/app/src/main/java/app/boxmanifest/data/NodeRepository.kt @@ -10,12 +10,12 @@ class NodeRepository( ) { suspend fun loadTree(): ApiNode = api.getTree() - suspend fun createChild(parentId: String, name: String, quantity: Long?) { - api.createNode(parentId, name, quantity) + suspend fun createChild(parentId: String, name: String, description: String?, quantity: Long?) { + api.createNode(parentId, name, description, quantity) } - suspend fun createChildren(parentId: String, names: List) { - api.createNodes(parentId, names) + suspend fun createChildren(parentId: String, names: List, description: String?) { + api.createNodes(parentId, names, description) } suspend fun previewTreeImport(parentId: String, manifestJson: String): TreeImportPreview = @@ -25,8 +25,8 @@ class NodeRepository( api.commitTreeImport(planId) } - suspend fun update(id: String, name: String, quantity: Long?) { - api.updateNode(id, name, quantity) + suspend fun update(id: String, name: String, description: String?, quantity: Long?) { + api.updateNode(id, name, description, quantity) } suspend fun move(id: String, targetParentId: String, childHandling: ChildHandling) { diff --git a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt index 6ca6128..668a261 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeScreen.kt @@ -199,12 +199,12 @@ fun TreeScreen( QuickAddDialog( parentName = node.name, onDismiss = { openSheet = null }, - onAddOne = { name, quantity -> - viewModel.createChild(name, quantity) + onAddOne = { name, description, quantity -> + viewModel.createChild(name, description, quantity) openSheet = null }, - onAddMultiple = { names -> - viewModel.createChildren(names) + onAddMultiple = { names, description -> + viewModel.createChildren(names, description) openSheet = null }, onImportTree = { @@ -234,11 +234,12 @@ fun TreeScreen( NodeEditorSheet( title = "Edit", initialName = node.name, + initialDescription = node.description, initialQuantity = node.quantity, actionLabel = "Save", onDismiss = { openSheet = null }, - onSubmit = { name, quantity -> - viewModel.updateCurrent(name, quantity) + onSubmit = { name, description, quantity -> + viewModel.updateCurrent(name, description, quantity) openSheet = null }, ) @@ -315,12 +316,13 @@ fun TreeScreen( private fun QuickAddDialog( parentName: String, onDismiss: () -> Unit, - onAddOne: (String, Long?) -> Unit, - onAddMultiple: (List) -> Unit, + onAddOne: (String, String?, Long?) -> Unit, + onAddMultiple: (List, String?) -> Unit, onImportTree: () -> Unit, ) { var input by remember { mutableStateOf("") } var quantity by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } var addMultiple by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current @@ -377,6 +379,14 @@ private fun QuickAddDialog( } } } + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (optional)") }, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + ) if (addMultiple) { Column { parsed.names.take(4).forEach { name -> @@ -408,9 +418,9 @@ private fun QuickAddDialog( Button( onClick = { if (addMultiple) { - onAddMultiple(parsed.names) + onAddMultiple(parsed.names, description.trim().ifBlank { null }) } else { - onAddOne(input.trim(), quantity.takeIf(String::isNotBlank)?.toLong()) + onAddOne(input.trim(), description.trim().ifBlank { null }, quantity.takeIf(String::isNotBlank)?.toLong()) } }, enabled = input.isNotBlank() && validQuantity && (!addMultiple || parsed.names.isNotEmpty()), @@ -692,6 +702,7 @@ private fun NodeScreen( onNavigate = onNavigateBreadcrumb, modifier = Modifier.fillMaxWidth(), ) + node.description?.let { PropertyRow("Description", it) } node.quantity?.let { PropertyRow("Quantity", it.toString()) } } if (node.children.isEmpty()) { @@ -724,12 +735,14 @@ private fun NodeScreen( private fun NodeEditorSheet( title: String, initialName: String, + initialDescription: String?, initialQuantity: Long?, actionLabel: String, onDismiss: () -> Unit, - onSubmit: (String, Long?) -> Unit, + onSubmit: (String, String?, Long?) -> Unit, ) { var name by remember(initialName) { mutableStateOf(initialName) } + var description by remember(initialDescription) { mutableStateOf(initialDescription.orEmpty()) } var quantity by remember(initialQuantity) { mutableStateOf(initialQuantity?.toString().orEmpty()) } val validQuantity = quantity.isBlank() || quantity.toLongOrNull()?.let { it >= 0 } == true @@ -739,6 +752,13 @@ private fun NodeEditorSheet( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(title, style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) OutlinedTextField( value = name, onValueChange = { name = it }, @@ -755,7 +775,7 @@ private fun NodeEditorSheet( singleLine = true, ) Button( - onClick = { onSubmit(name.trim(), quantity.takeIf { it.isNotBlank() }?.toLong()) }, + onClick = { onSubmit(name.trim(), description.trim().ifBlank { null }, quantity.takeIf { it.isNotBlank() }?.toLong()) }, enabled = name.isNotBlank() && validQuantity, modifier = Modifier.fillMaxWidth(), ) { Text(actionLabel) } diff --git a/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt index 8479d70..26de6c2 100644 --- a/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt +++ b/app/src/main/java/app/boxmanifest/tree/TreeViewModel.kt @@ -51,13 +51,13 @@ class TreeViewModel( return true } - fun createChild(name: String, quantity: Long?) = perform { - repository.createChild(_state.value.currentNodeId, name, quantity) + fun createChild(name: String, description: String?, quantity: Long?) = perform { + repository.createChild(_state.value.currentNodeId, name, description, quantity) repository.loadTree() } - fun createChildren(names: List) = perform { - repository.createChildren(_state.value.currentNodeId, names) + fun createChildren(names: List, description: String?) = perform { + repository.createChildren(_state.value.currentNodeId, names, description) repository.loadTree() } @@ -88,8 +88,8 @@ class TreeViewModel( } } - fun updateCurrent(name: String, quantity: Long?) = perform { - repository.update(_state.value.currentNodeId, name, quantity) + fun updateCurrent(name: String, description: String?, quantity: Long?) = perform { + repository.update(_state.value.currentNodeId, name, description, quantity) repository.loadTree() } diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 094a545..2394382 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -64,27 +64,31 @@ Web ───────┘ - `server/`: Go standard-library HTTP handlers and SQLite persistence - `app/`: Kotlin, Jetpack Compose, coroutines, and a small handwritten client -- `web/`: dependency-free diagnostic UI +- `web/`: Vite, React, and TypeScript client - `api/`: OpenAPI contract and tree-import JSON Schema - `docs/`: product and architecture decisions The API is intentionally unversioned during pre-release development. +Web styling follows the pinned [desktop design reference](design/DESKTOP_DESIGN_REFERENCE.md). +Visual changes are reviewed against its exact tokens, fixtures, and documented +Box Manifest deviations rather than a general product resemblance. + ## Implemented vertical slices - Tree creation, reading, editing, moving, and empty-only deletion - Bulk sibling creation and multi-selection move/delete -- Android hierarchy navigation, breadcrumbs, and local search +- Android and web hierarchy navigation, breadcrumbs, and local search - AI-oriented nested JSON import with preview and atomic commit - Stable lookup codes and canonical `/n/{code}` routes -- Android QR-label preview, sharing, and scanning +- Android and web QR-label preview/export, plus Android camera scanning and Find ## Near-term direction -Continue validating the Android interaction model and the server API against -real inventory use. Prefer small end-to-end increments that exercise storage, -API behavior, and the Android client together. Keep the web UI diagnostic until -the core model and Android workflows have settled. +Continue validating the shared Android/web interaction model and the server API +against real inventory use. Prefer small end-to-end increments that exercise +storage, API behavior, and both clients together. Camera-dependent workflows +remain Android-specific. Before permanent QR labels are printed, make the server's externally reachable canonical URL configurable. Before deployment work begins, establish explicit diff --git a/docs/WEB_LABEL_SHEET_GENERATION.md b/docs/WEB_LABEL_SHEET_GENERATION.md new file mode 100644 index 0000000..20ba442 --- /dev/null +++ b/docs/WEB_LABEL_SHEET_GENERATION.md @@ -0,0 +1,75 @@ +# Web Label-Sheet Generation + +Status: first increment implemented. The web UI can generate QR-code label-sheet +PDFs for one or more selected nodes using a built-in US Letter 3 × 10 template, +choose a starting label position, and preview every output page. + +The template catalog now supports Letter, A4, and custom page sizes; generic +physical templates with manufacturer compatibility aliases; editable custom +geometry; fit validation; and persisted template selection. Initial aliases +cover Avery 5160/8160/18160, 5161/8161, 5163/8163, and A4 L7160 geometries. + +Build a generic adhesive label-sheet generation system for the web UI, +independent of Avery or any other manufacturer. + +## Core architecture + +```typescript +interface LabelSheetTemplate { + name: string; + pageWidthMm: number; + pageHeightMm: number; + rows: number; + columns: number; + labelWidthMm: number; + labelHeightMm: number; + marginTopMm: number; + marginLeftMm: number; + horizontalGapMm: number; + verticalGapMm: number; + cornerRadiusMm?: number; + bleedMm?: number; +} +``` + +Use deterministic placement: + +```typescript +x = marginLeft + col * (labelWidth + horizontalGap) +y = marginTop + row * (labelHeight + verticalGap) +``` + +Keep these concerns separate: + +```text +LabelDesign = content/layout of one label +LabelSheetTemplate = physical geometry of the sheet +PrintJob = data + selected labels + occupied/unused positions +``` + +## Requirements + +- Arbitrary manufacturer sheet dimensions +- Letter, A4, and custom page sizes +- Start-at-label and skip-used-label positions +- Printer calibration offsets and optional scaling +- PDF generation using physical units +- Printing at 100% / Actual Size +- Text, QR codes, barcodes, images, lines, and rectangles +- Validation that rows and columns fit within the page +- Preview before printing + +## Target workflow + +```text +Select structured inventory data +→ choose label design +→ choose sheet template +→ choose available sheet positions +→ preview +→ generate PDF +→ print +``` + +The goal is to provide useful label-sheet design and printing while remaining +completely manufacturer-independent. diff --git a/docs/design/DESKTOP_DESIGN_REFERENCE.md b/docs/design/DESKTOP_DESIGN_REFERENCE.md new file mode 100644 index 0000000..f997b02 --- /dev/null +++ b/docs/design/DESKTOP_DESIGN_REFERENCE.md @@ -0,0 +1,352 @@ +# Desktop Design Reference + +Status: canonical visual specification for the Box Manifest web client. + +## Reference baseline + +Box Manifest uses Anytype Desktop as its single primary web design reference. +The baseline is frozen so that design decisions can be compared against a +stable implementation instead of a changing product or a general impression. + +| Field | Baseline | +| --- | --- | +| Product | Anytype Desktop | +| Repository | `anyproto/anytype-ts` | +| Release | `v0.56.8-beta` | +| Commit | `2b71bd44da8b7e20a689585bceef5f1e3251cf72` | +| Release date | 2026-08-26 | + +Reference links: + +- [Pinned release](https://github.com/anyproto/anytype-ts/releases/tag/v0.56.8-beta) +- [Pinned source tree](https://github.com/anyproto/anytype-ts/tree/2b71bd44da8b7e20a689585bceef5f1e3251cf72) +- [Design variables](https://github.com/anyproto/anytype-ts/blob/2b71bd44da8b7e20a689585bceef5f1e3251cf72/src/scss/_vars.scss) +- [Geometry constants](https://github.com/anyproto/anytype-ts/blob/2b71bd44da8b7e20a689585bceef5f1e3251cf72/src/json/size.ts) + +Attio, Linear, Airtable, Outline, and other products are not secondary visual +references. They may help explain a product problem, but they must not be used +to select styling. If the Anytype reference does not cover a Box Manifest +workflow, the new design must be documented here as a Box Manifest extension. + +## Decision rule + +Match the pinned Anytype behavior and styling unless Box Manifest has a +specific functional or cross-platform reason to differ. Every deliberate +deviation must appear in the deviation table below before it is implemented. + +Do not introduce styling solely because it is conventional in admin +dashboards. In particular, avoid decorative cards, persistent borders, +gratuitous headings, colored gradients, arbitrary shadows, and multiple +competing accent colors. + +## Source-backed tokens + +These values were read from the pinned source. They are the starting point for +web design tokens rather than approximate values sampled from marketing images. + +### Typography + +Anytype uses Inter for interface text. + +| Token | Size | Line height | Tracking | Weight | +| --- | ---: | ---: | ---: | ---: | +| Very small | 11px | 18px | 0.2px | 400–500 | +| Small | 12px | 18px | 0 | 400–500 | +| Common | 14px | 22px | -0.12px | 400–600 | +| Paragraph | 16px | 24px | -0.2px | 400–600 | +| Header 3 | 18px | 26px | -0.28px | 700 | +| Header 2 | 22px | 28px | -0.48px | 700 | +| Header 1 | 28px | 32px | -0.56px | 700 | +| Title | 36px | 40px | -0.64px | 700 | + +Rules: + +- Use 14px/22px for ordinary controls and hierarchy rows. +- Use 12px/18px for metadata, section labels, and keyboard hints. +- Use 16px/24px for prominent labels and explanatory copy. +- A workspace heading is 28px, not a marketing-page hero heading. +- Do not use weights between the available Inter weights merely to create + hierarchy; use size, spacing, and primary/secondary text colors first. + +### Light palette + +| Role | Value | +| --- | --- | +| Primary text | `#252525` | +| Secondary text | `#828282` | +| Tertiary text | `#bfbfbf` | +| Primary background | `#ffffff` | +| Primary shape | `#e3e3e3` | +| Secondary shape | `#ebebeb` | +| Tertiary shape | `#f2f2f2` | +| Solid subtle surface | `#f7f7f7` | +| Medium hover | `rgba(0, 0, 0, 0.05)` | +| Strong hover | `rgba(0, 0, 0, 0.11)` | +| Control accent | `#252525` | +| System accent | `#377aff` | +| Selection | `rgba(55, 122, 255, 0.25)` | +| Destructive | `#fb592c` | +| Destructive surface | `#fee7e0` | + +### Dark palette + +| Role | Value | +| --- | --- | +| Primary text | `#e1e1e1` | +| Secondary text | `#a3a3a3` | +| Tertiary text | `#5c5c5c` | +| Primary background | `#171717` | +| Primary shape | `#313131` | +| Secondary shape | `#292929` | +| Tertiary shape | `#232323` | +| Solid subtle surface | `#1e1e1e` | +| Medium hover | `rgba(255, 255, 255, 0.05)` | +| Strong hover | `rgba(255, 255, 255, 0.11)` | +| Control accent | `#d4d4d4` | +| Selection | `rgba(55, 122, 255, 0.25)` | +| Destructive surface | `#3b251e` | + +### Geometry + +| Element | Reference geometry | +| --- | --- | +| Application header | 52px high | +| Primary left sidebar | 284px default; 72–480px allowed | +| Secondary/right panel | 336px default; 240–480px allowed | +| Main content/editor width | 704px where constrained | +| Standard button | 40px high, 20px radius, 16px horizontal padding | +| Button variants | 48/24, 36/18, 32/16, 28/14 height/radius | +| Standard input | 40px high, 20px radius, 16px horizontal padding | +| Compact input | 28px high, 14px radius | +| Popup | 24px radius, `0 2px 28px rgba(0,0,0,.2)` | +| Menu | 12px radius, 8px vertical interior padding | +| Menu item | 8px radius, 4px × 16px padding | +| Menu item with description | 56px high with 40px icon | +| Search popup | 684px × 706px, max-height `viewport - 48px` | +| Regular list row | 52px high | +| Compact inline list row | 28px high | +| Large object icon | 40px square, 10px radius | +| Sidebar section | 12px radius | + +### Motion + +| Interaction | Reference | +| --- | --- | +| Common transition | 150ms | +| Menu, popup, sidebar | 200ms | +| Standard easing | `cubic-bezier(0.22, 1, 0.36, 1)` | +| Sidebar easing | `cubic-bezier(0.2, 0, 0, 1)` | +| Popup entrance | opacity plus scale from 0.95 | + +Motion must communicate state or spatial origin. Avoid decorative perpetual +motion. Honor `prefers-reduced-motion` by removing transforms and shortening +nonessential transitions. + +## Reference fixtures + +The pinned repository includes Storybook fixtures that define the exact visual +states to compare against. These source fixtures are the reproducible visual +reference; a screenshot used during review must name the fixture and commit. + +| Box Manifest concern | Anytype fixture/source | +| --- | --- | +| Application header | `component/header/main/object.stories.tsx` | +| Sidebar shell | `component/sidebar/page/widget.scss` and sidebar stories | +| Object list | `component/block/dataview/view/list.stories.tsx` | +| Regular object row | `component/block/dataview/view/list/row.stories.tsx` | +| Data grid | `component/block/dataview/view/grid.stories.tsx` | +| Multi-selection controls | `component/block/dataview/selection.stories.tsx` | +| Search | `component/popup/search.stories.tsx` | +| Object menu | `component/menu/object.stories.tsx` | +| Context menu | `component/menu/object/context.stories.tsx` | +| Buttons | `component/form/button.stories.tsx` | +| Inputs | `component/form/input.stories.tsx` | +| Select controls | `component/form/select.stories.tsx` | +| Confirmation | `component/popup/confirm.stories.tsx` | +| Import page | `component/page/main/import.stories.tsx` | +| Empty results | `component/util/emptySearch.stories.tsx` | +| Toast feedback | `component/toast.scss` | + +Screenshots are review artifacts, not design authority by themselves. Capture +light and dark versions at the reference commit and label each image with its +fixture name. Do not compare against an unlabeled image search result or a +newer Anytype build. + +## Box Manifest screen mapping + +### Application shell + +Use the Anytype left sidebar plus 52px common header. The sidebar holds only +workspace-level navigation. It must not duplicate the inventory hierarchy, +which belongs in the main workspace. + +Box Manifest extensions: + +- `Inventory` and `Import` are the only current primary navigation entries. +- The node details inspector uses the 336px secondary-panel geometry. +- The sidebar may collapse responsively; it is not duplicated above content. + +### Inventory hierarchy + +Use the regular 52px Anytype list rhythm, 14px primary names, 12px secondary +metadata, subtle 5% hover surface, and blue system selection for explicit +multi-selection. Expansion is an interaction required by Box Manifest and is +rendered using the reference 20px chevrons and 16px depth increments. + +The current-node focus and multi-selection are distinct states: + +- Current node opens the inspector and uses a subtle neutral focus surface. +- Checked nodes use the system-selection color and expose the selection bar. +- Hover must not be visually stronger than either selected state. + +### Node details + +Use the Anytype secondary sidebar, not a dashboard card. Properties are simple +rows with subdued labels. Editing can occur inline. Destructive actions live +at the end of the action group and use the system destructive color. + +### Search + +Desktop search follows the Anytype 684px centered search popup: + +- 54px search input row +- 18px search icon +- Results with a bold name and 12px hierarchy context +- 8px result radius and 12px inter-element gap +- Empty state from the pinned empty-search fixture +- Keyboard navigation and `Ctrl/Command+K` + +Search is not a permanently expanded toolbar field in the final design. + +### Quick add + +Use the Anytype popup composition and controls. The text input receives focus +on open. Delimiter detection reveals the multi-add option without changing the +default single-add behavior. Quantity disappears when multi-add is selected. + +### Move + +Use a common popup containing a compact, expandable hierarchy. Destination +selection follows an Anytype menu/radio treatment. Child-handling options are +visually subordinate to the destination because they are an advanced semantic +choice, not a peer navigation task. + +### Multi-selection + +Use Anytype blue system selection, not a permanent checkbox column that makes +the hierarchy resemble a settings form. Check controls appear on row hover or +after selection mode begins. Bulk actions are presented in one contextual +selection bar and disappear when selection is cleared. + +### Import + +Use the Anytype main import-page composition. Input and preview are sequential +states on narrow screens and may be adjacent on wide screens. Preview is a +hierarchy, never normalized JSON text. The destructive/committing step uses a +clear primary action and states explicitly that preview has not changed data. + +### Label preview + +Anytype does not provide the physical-label workflow. This is a documented Box +Manifest extension. Its surrounding popup, buttons, loading treatment, and +error states still use the reference system. The label artwork itself remains +black on white so printing is deterministic in both themes. + +## Responsive rules + +Anytype Desktop is the desktop reference, but the browser must remain usable at +mobile widths: + +- At 760px and below, hide the workspace sidebar. +- Present the hierarchy as the primary page. +- Present node details as a separate full-width state, not appended below a + long hierarchy. +- Present dialogs as bottom sheets with the same content hierarchy used by the + Android client. +- Maintain at least 44px pointer targets where touch is expected even when the + Anytype desktop fixture uses a smaller control. + +## Deliberate deviations + +| Deviation | Reason | +| --- | --- | +| Android uses `#171717` text and `#fcfcfa` paper rather than Anytype's `#252525` and white | Existing shared mobile identity; web migration should be evaluated side by side before changing both clients | +| Universal expandable hierarchy instead of Anytype object sets/dataviews | Tree containment is the core Box Manifest domain model | +| 44px minimum touch targets on narrow web layouts | Browser UI is expected to work on phones | +| Label preview and label-sheet workflow | Product-specific physical-output requirement | +| Camera scan and Find absent from web | Explicit platform capability boundary | + +No other deviation is currently approved. + +## Current implementation audit + +The first reference-matching pass is complete. It aligned: + +- The 52px application header, 284px primary sidebar, and 336px inspector +- The 28px/700 workspace heading and 14px/22px interface type rhythm +- Locally bundled Inter at weights 400, 500, 600, and 700 +- The centered 684px search popup, keyboard navigation, and empty state +- The regular 52px hierarchy row and 16px nesting increment +- Hover-revealed selection controls and blue system-selection treatment +- 40px pill buttons, pill inputs, 24px popups, reference shadow, and entrance + motion +- Mobile bottom sheets and separate mobile hierarchy/detail states +- Visible focus treatment and reduced-motion behavior + +Remaining differences that require a later deliberate pass: + +- The primary sidebar starts at 284px but is not yet user-resizable. +- Automated screenshot capture does not yet cover every review-matrix state. +- The import workflow is a Box Manifest two-pane extension and still needs a + dedicated comparison plate against the Anytype import fixture. +- Some native browser checkbox and select rendering remains platform-dependent. + +## Review matrix + +Every styling pull request must include comparisons at these viewports: + +| Viewport | Theme | +| --- | --- | +| 1440 × 900 | Light and dark | +| 1024 × 768 | Light and dark | +| 390 × 844 | Light and dark | + +Capture these Box Manifest states: + +1. Inventory with nested nodes expanded +2. Node selected with details visible +3. Multi-selection with contextual actions +4. Search with results +5. Search empty state +6. Quick-add single mode +7. Quick-add multi mode +8. Move hierarchy +9. Import input +10. Import preview +11. Label preview +12. Loading, error, and empty inventory states + +For each state, verify: + +- Token values match this document. +- Primary and secondary hierarchy match the named Anytype fixture. +- Default, hover, focus-visible, selected, disabled, loading, and error states + exist where applicable. +- Keyboard focus is visible and follows interaction order. +- No content shifts when hover-only controls appear. +- No unexplained border, radius, shadow, size, color, or animation was added. +- Any deviation is already present in the deviation table. + +## Change control + +Changing the Anytype baseline requires an explicit design-reference update, +including a new tag, commit, token audit, and fixture review. Application code +must not silently track newer Anytype releases. + +When Box Manifest needs a pattern absent from Anytype: + +1. Describe the product constraint. +2. Design the minimum extension using existing reference tokens. +3. Add the decision to the deviation table. +4. Capture all required states in the review matrix. +5. Only then implement the component. diff --git a/server/cmd/box-manifest-server/main.go b/server/cmd/box-manifest-server/main.go index cd22d2d..de55505 100644 --- a/server/cmd/box-manifest-server/main.go +++ b/server/cmd/box-manifest-server/main.go @@ -18,7 +18,7 @@ import ( func main() { address := flag.String("address", ":8080", "HTTP listen address") databasePath := flag.String("database", "data/box-manifest.db", "SQLite database path") - webPath := flag.String("web", "../web", "web client directory") + webPath := flag.String("web", "../web/dist", "built web client directory") flag.Parse() if err := os.MkdirAll(filepath.Dir(*databasePath), 0o755); err != nil { diff --git a/server/internal/httpapi/handler.go b/server/internal/httpapi/handler.go index e0a9573..48f7de6 100644 --- a/server/internal/httpapi/handler.go +++ b/server/internal/httpapi/handler.go @@ -20,15 +20,27 @@ func New(store *tree.Store) http.Handler { h := &Handler{store: store, imports: importer.New(store)} mux := http.NewServeMux() mux.HandleFunc("GET /api/tree", h.getTree) + mux.HandleFunc("GET /api/node-types", h.listNodeTypes) + mux.HandleFunc("POST /api/node-types", h.createNodeType) + mux.HandleFunc("PUT /api/node-types/{typeId}", h.updateNodeType) + mux.HandleFunc("DELETE /api/node-types/{typeId}", h.deleteNodeType) + mux.HandleFunc("GET /api/tags", h.listTags) + mux.HandleFunc("POST /api/tags", h.createTag) + mux.HandleFunc("PUT /api/tags/{tagId}", h.updateTag) + mux.HandleFunc("DELETE /api/tags/{tagId}", h.deleteTag) mux.HandleFunc("POST /api/nodes", h.createNode) mux.HandleFunc("POST /api/nodes/bulk", h.createNodesBulk) mux.HandleFunc("POST /api/nodes/bulk/move", h.moveNodesBulk) mux.HandleFunc("POST /api/nodes/bulk/delete", h.deleteNodesBulk) + mux.HandleFunc("POST /api/nodes/bulk/combine", h.combineNodes) + mux.HandleFunc("POST /api/nodes/bulk/classification", h.classifyNodesBulk) mux.HandleFunc("GET /api/nodes/{nodeId}", h.getNode) mux.HandleFunc("GET /api/nodes/by-code/{lookupCode}", h.getNodeByLookupCode) mux.HandleFunc("PATCH /api/nodes/{nodeId}", h.updateNode) mux.HandleFunc("DELETE /api/nodes/{nodeId}", h.deleteNode) mux.HandleFunc("POST /api/nodes/{nodeId}/move", h.moveNode) + mux.HandleFunc("POST /api/nodes/{nodeId}/individualize", h.individualizeNode) + mux.HandleFunc("PUT /api/nodes/{nodeId}/classification", h.classifyNode) mux.HandleFunc("POST /api/imports/tree/preview", h.previewTreeImport) mux.HandleFunc("POST /api/imports/tree/commit", h.commitTreeImport) mux.HandleFunc("GET /n/{lookupCode}", h.openCanonicalNode) @@ -36,14 +48,16 @@ func New(store *tree.Store) http.Handler { } type createNodeRequest struct { - ParentID *string `json:"parentId"` - Name string `json:"name"` - Quantity *int64 `json:"quantity"` + ParentID *string `json:"parentId"` + Name string `json:"name"` + Description *string `json:"description"` + Quantity *int64 `json:"quantity"` } type createNodesBulkRequest struct { - ParentID string `json:"parentId"` - Names []string `json:"names"` + ParentID string `json:"parentId"` + Names []string `json:"names"` + Description *string `json:"description"` } type createNodesBulkResponse struct { @@ -64,6 +78,40 @@ type deleteNodesBulkRequest struct { NodeIDs []string `json:"nodeIds"` } +type individualizeNodeRequest struct { + Names []string `json:"names"` +} +type individualizeNodeResponse struct { + Nodes []tree.Node `json:"nodes"` +} +type combineNodesRequest struct { + NodeIDs []string `json:"nodeIds"` + RetainedNodeID string `json:"retainedNodeId"` + Name string `json:"name"` +} + +type nodeTypeRequest struct { + Name string `json:"name"` + Description *string `json:"description"` + IconKey *string `json:"iconKey"` + Color *string `json:"color"` +} +type tagRequest struct { + Name string `json:"name"` + Color *string `json:"color"` +} +type classificationRequest struct { + TypeID *string `json:"typeId"` + TagIDs []string `json:"tagIds"` +} +type bulkClassificationRequest struct { + NodeIDs []string `json:"nodeIds"` + SetType bool `json:"setType"` + TypeID *string `json:"typeId"` + AddTagIDs []string `json:"addTagIds"` + RemoveTagIDs []string `json:"removeTagIds"` +} + type previewTreeImportRequest struct { ParentID string `json:"parentId"` Manifest importer.Manifest `json:"manifest"` @@ -82,6 +130,20 @@ type optionalInt64 struct { Value *int64 } +type optionalString struct { + Set bool + Value *string +} + +func (o *optionalString) UnmarshalJSON(data []byte) error { + o.Set = true + if string(data) == "null" { + o.Value = nil + return nil + } + return json.Unmarshal(data, &o.Value) +} + func (o *optionalInt64) UnmarshalJSON(data []byte) error { o.Set = true if string(data) == "null" { @@ -92,8 +154,9 @@ func (o *optionalInt64) UnmarshalJSON(data []byte) error { } type updateNodeRequest struct { - Name *string `json:"name"` - Quantity optionalInt64 `json:"quantity"` + Name *string `json:"name"` + Description optionalString `json:"description"` + Quantity optionalInt64 `json:"quantity"` } type moveNodeRequest struct { @@ -115,13 +178,96 @@ func (h *Handler) getTree(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, root) } +func (h *Handler) listNodeTypes(w http.ResponseWriter, r *http.Request) { + values, err := h.store.ListNodeTypes(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, values) +} +func (h *Handler) createNodeType(w http.ResponseWriter, r *http.Request) { + var request nodeTypeRequest + if decodeJSON(r, &request) != nil { + writeError(w, tree.ErrInvalid) + return + } + value, err := h.store.CreateNodeType(r.Context(), request.Name, request.Description, request.IconKey, request.Color) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, value) +} +func (h *Handler) updateNodeType(w http.ResponseWriter, r *http.Request) { + var request nodeTypeRequest + if decodeJSON(r, &request) != nil { + writeError(w, tree.ErrInvalid) + return + } + value, err := h.store.UpdateNodeType(r.Context(), r.PathValue("typeId"), request.Name, request.Description, request.IconKey, request.Color) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, value) +} +func (h *Handler) deleteNodeType(w http.ResponseWriter, r *http.Request) { + if err := h.store.DeleteNodeType(r.Context(), r.PathValue("typeId")); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) { + values, err := h.store.ListTags(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, values) +} +func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) { + var request tagRequest + if decodeJSON(r, &request) != nil { + writeError(w, tree.ErrInvalid) + return + } + value, err := h.store.CreateTag(r.Context(), request.Name, request.Color) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, value) +} +func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) { + var request tagRequest + if decodeJSON(r, &request) != nil { + writeError(w, tree.ErrInvalid) + return + } + value, err := h.store.UpdateTag(r.Context(), r.PathValue("tagId"), request.Name, request.Color) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, value) +} +func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) { + if err := h.store.DeleteTag(r.Context(), r.PathValue("tagId")); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) createNode(w http.ResponseWriter, r *http.Request) { var request createNodeRequest if err := decodeJSON(r, &request); err != nil { writeError(w, tree.ErrInvalid) return } - node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Quantity) + node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Description, request.Quantity) if err != nil { writeError(w, err) return @@ -135,7 +281,7 @@ func (h *Handler) createNodesBulk(w http.ResponseWriter, r *http.Request) { writeError(w, tree.ErrInvalid) return } - nodes, err := h.store.CreateMany(r.Context(), request.ParentID, request.Names) + nodes, err := h.store.CreateMany(r.Context(), request.ParentID, request.Names, request.Description) if err != nil { writeError(w, err) return @@ -172,6 +318,61 @@ func (h *Handler) deleteNodesBulk(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) individualizeNode(w http.ResponseWriter, r *http.Request) { + var request individualizeNodeRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, tree.ErrInvalid) + return + } + nodes, err := h.store.Individualize(r.Context(), r.PathValue("nodeId"), request.Names) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, individualizeNodeResponse{Nodes: nodes}) +} + +func (h *Handler) combineNodes(w http.ResponseWriter, r *http.Request) { + var request combineNodesRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, tree.ErrInvalid) + return + } + node, err := h.store.Combine(r.Context(), request.NodeIDs, request.RetainedNodeID, request.Name) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, node) +} + +func (h *Handler) classifyNode(w http.ResponseWriter, r *http.Request) { + var request classificationRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, tree.ErrInvalid) + return + } + node, err := h.store.SetClassification(r.Context(), r.PathValue("nodeId"), request.TypeID, request.TagIDs) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, node) +} + +func (h *Handler) classifyNodesBulk(w http.ResponseWriter, r *http.Request) { + var request bulkClassificationRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, tree.ErrInvalid) + return + } + if err := h.store.ClassifyMany(r.Context(), request.NodeIDs, request.SetType, request.TypeID, request.AddTagIDs, request.RemoveTagIDs); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) previewTreeImport(w http.ResponseWriter, r *http.Request) { var request previewTreeImportRequest if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.ParentID) == "" { @@ -233,7 +434,7 @@ func (h *Handler) updateNode(w http.ResponseWriter, r *http.Request) { writeError(w, tree.ErrInvalid) return } - node, err := h.store.Update(r.Context(), r.PathValue("nodeId"), request.Name, tree.QuantityUpdate{ + node, err := h.store.Update(r.Context(), r.PathValue("nodeId"), request.Name, tree.StringUpdate{Set: request.Description.Set, Value: request.Description.Value}, tree.QuantityUpdate{ Set: request.Quantity.Set, Value: request.Quantity.Value, }) diff --git a/server/internal/httpapi/handler_test.go b/server/internal/httpapi/handler_test.go index 2c6048f..7a16883 100644 --- a/server/internal/httpapi/handler_test.go +++ b/server/internal/httpapi/handler_test.go @@ -103,6 +103,73 @@ func TestBulkMoveAndDeleteThroughHTTP(t *testing.T) { } } +func TestIndividualizeAndCombineThroughHTTP(t *testing.T) { + handler := newTestHandler(t) + create := request(t, handler, http.MethodPost, "/api/nodes", `{"parentId":"unsorted","name":"Bin","quantity":2}`) + var original tree.Node + if err := json.Unmarshal(create.Body.Bytes(), &original); err != nil { + t.Fatal(err) + } + individualize := request(t, handler, http.MethodPost, "/api/nodes/"+original.ID+"/individualize", `{"names":["Bin 01","Bin 02"]}`) + if individualize.Code != http.StatusOK { + t.Fatalf("individualize: got %d: %s", individualize.Code, individualize.Body.String()) + } + var body struct { + Nodes []tree.Node `json:"nodes"` + } + if err := json.Unmarshal(individualize.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Nodes) != 2 || body.Nodes[0].ID != original.ID { + t.Fatalf("unexpected units: %#v", body.Nodes) + } + combineBody, _ := json.Marshal(map[string]any{"nodeIds": []string{body.Nodes[0].ID, body.Nodes[1].ID}, "retainedNodeId": body.Nodes[0].ID, "name": "Bins"}) + combine := request(t, handler, http.MethodPost, "/api/nodes/bulk/combine", string(combineBody)) + if combine.Code != http.StatusOK { + t.Fatalf("combine: got %d: %s", combine.Code, combine.Body.String()) + } + var combined tree.Node + if err := json.Unmarshal(combine.Body.Bytes(), &combined); err != nil { + t.Fatal(err) + } + if combined.Quantity == nil || *combined.Quantity != 2 || combined.LookupCode != original.LookupCode { + t.Fatalf("unexpected combined node: %#v", combined) + } +} + +func TestTypesTagsAndClassificationThroughHTTP(t *testing.T) { + handler := newTestHandler(t) + node := createNodeThroughHTTP(t, handler, tree.UnsortedID, "Box") + typeResponse := request(t, handler, http.MethodPost, "/api/node-types", `{"name":"Storage bin","color":"#377aff"}`) + if typeResponse.Code != http.StatusCreated { + t.Fatalf("create type: %d %s", typeResponse.Code, typeResponse.Body.String()) + } + var nodeType tree.NodeType + if err := json.Unmarshal(typeResponse.Body.Bytes(), &nodeType); err != nil { + t.Fatal(err) + } + tagResponse := request(t, handler, http.MethodPost, "/api/tags", `{"name":"Blue"}`) + if tagResponse.Code != http.StatusCreated { + t.Fatalf("create tag: %d %s", tagResponse.Code, tagResponse.Body.String()) + } + var tag tree.Tag + if err := json.Unmarshal(tagResponse.Body.Bytes(), &tag); err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]any{"typeId": nodeType.ID, "tagIds": []string{tag.ID}}) + classified := request(t, handler, http.MethodPut, "/api/nodes/"+node.ID+"/classification", string(body)) + if classified.Code != http.StatusOK { + t.Fatalf("classify: %d %s", classified.Code, classified.Body.String()) + } + var result tree.Node + if err := json.Unmarshal(classified.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.TypeID == nil || *result.TypeID != nodeType.ID || len(result.TagIDs) != 1 { + t.Fatalf("classification missing: %#v", result) + } +} + func TestTreeImportPreviewAndCommitThroughHTTP(t *testing.T) { handler := newTestHandler(t) preview := request( diff --git a/server/internal/importer/service.go b/server/internal/importer/service.go index 564d1c9..09c45f3 100644 --- a/server/internal/importer/service.go +++ b/server/internal/importer/service.go @@ -123,7 +123,14 @@ func normalize(manifest Manifest) (Manifest, Summary, error) { if err != nil { return nil, err } - normalized[index] = tree.ImportNode{Name: name, Quantity: node.Quantity, Children: children} + var description *string + if node.Description != nil { + trimmed := strings.TrimSpace(*node.Description) + if trimmed != "" { + description = &trimmed + } + } + normalized[index] = tree.ImportNode{Name: name, Description: description, Quantity: node.Quantity, Children: children} if depth > maximumDepth { maximumDepth = depth } diff --git a/server/internal/tree/metadata.go b/server/internal/tree/metadata.go new file mode 100644 index 0000000..6b5dcfa --- /dev/null +++ b/server/internal/tree/metadata.go @@ -0,0 +1,319 @@ +package tree + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +type NodeType struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + IconKey *string `json:"iconKey"` + Color *string `json:"color"` +} + +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` + Color *string `json:"color"` +} + +func (s *Store) ListNodeTypes(ctx context.Context) ([]NodeType, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types ORDER BY name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []NodeType{} + for rows.Next() { + value, err := scanNodeType(rows) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) CreateNodeType(ctx context.Context, name string, description, iconKey, color *string) (NodeType, error) { + name = strings.TrimSpace(name) + if name == "" { + return NodeType{}, ErrInvalid + } + id, err := newID() + if err != nil { + return NodeType{}, err + } + if _, err = s.db.ExecContext(ctx, `INSERT INTO node_types (id, name, description, icon_key, color) VALUES (?, ?, ?, ?, ?)`, id, name, cleanOptionalString(description), cleanOptionalString(iconKey), cleanOptionalString(color)); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return NodeType{}, ErrConflict + } + return NodeType{}, err + } + return s.GetNodeType(ctx, id) +} + +func (s *Store) GetNodeType(ctx context.Context, id string) (NodeType, error) { + return scanNodeType(s.db.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, id)) +} + +func (s *Store) UpdateNodeType(ctx context.Context, id, name string, description, iconKey, color *string) (NodeType, error) { + name = strings.TrimSpace(name) + if name == "" { + return NodeType{}, ErrInvalid + } + result, err := s.db.ExecContext(ctx, `UPDATE node_types SET name = ?, description = ?, icon_key = ?, color = ? WHERE id = ?`, name, cleanOptionalString(description), cleanOptionalString(iconKey), cleanOptionalString(color), id) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return NodeType{}, ErrConflict + } + return NodeType{}, err + } + if count, _ := result.RowsAffected(); count == 0 { + return NodeType{}, ErrNotFound + } + return s.GetNodeType(ctx, id) +} + +func (s *Store) DeleteNodeType(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM node_types WHERE id = ?`, id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) ListTags(ctx context.Context) ([]Tag, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, color FROM tags ORDER BY name COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Tag{} + for rows.Next() { + value, err := scanTag(rows) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (s *Store) CreateTag(ctx context.Context, name string, color *string) (Tag, error) { + name = strings.TrimSpace(name) + if name == "" { + return Tag{}, ErrInvalid + } + id, err := newID() + if err != nil { + return Tag{}, err + } + if _, err = s.db.ExecContext(ctx, `INSERT INTO tags (id, name, color) VALUES (?, ?, ?)`, id, name, cleanOptionalString(color)); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return Tag{}, ErrConflict + } + return Tag{}, err + } + return s.GetTag(ctx, id) +} + +func (s *Store) GetTag(ctx context.Context, id string) (Tag, error) { + return scanTag(s.db.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, id)) +} + +func (s *Store) UpdateTag(ctx context.Context, id, name string, color *string) (Tag, error) { + name = strings.TrimSpace(name) + if name == "" { + return Tag{}, ErrInvalid + } + result, err := s.db.ExecContext(ctx, `UPDATE tags SET name = ?, color = ? WHERE id = ?`, name, cleanOptionalString(color), id) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return Tag{}, ErrConflict + } + return Tag{}, err + } + if count, _ := result.RowsAffected(); count == 0 { + return Tag{}, ErrNotFound + } + return s.GetTag(ctx, id) +} + +func (s *Store) DeleteTag(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, id) + if err != nil { + return err + } + if count, _ := result.RowsAffected(); count == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) SetClassification(ctx context.Context, nodeID string, typeID *string, tagIDs []string) (Node, error) { + if hasDuplicateIDsAllowEmpty(tagIDs) { + return Node{}, ErrInvalid + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Node{}, err + } + defer tx.Rollback() + if _, err := getWith(ctx, tx, nodeID); err != nil { + return Node{}, err + } + if typeID != nil { + if _, err := scanNodeType(tx.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, *typeID)); err != nil { + return Node{}, err + } + } + for _, tagID := range tagIDs { + if _, err := scanTag(tx.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, tagID)); err != nil { + return Node{}, err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE nodes SET type_id = ? WHERE id = ?`, typeID, nodeID); err != nil { + return Node{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM node_tags WHERE node_id = ?`, nodeID); err != nil { + return Node{}, err + } + for _, tagID := range tagIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO node_tags (node_id, tag_id) VALUES (?, ?)`, nodeID, tagID); err != nil { + return Node{}, err + } + } + if err := tx.Commit(); err != nil { + return Node{}, err + } + return s.Get(ctx, nodeID) +} + +func (s *Store) ClassifyMany(ctx context.Context, nodeIDs []string, setType bool, typeID *string, addTagIDs, removeTagIDs []string) error { + if len(nodeIDs) == 0 || hasDuplicateIDs(nodeIDs) || hasDuplicateIDsAllowEmpty(addTagIDs) || hasDuplicateIDsAllowEmpty(removeTagIDs) { + return ErrInvalid + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if typeID != nil { + if _, err := scanNodeType(tx.QueryRowContext(ctx, `SELECT id, name, description, icon_key, color FROM node_types WHERE id = ?`, *typeID)); err != nil { + return err + } + } + for _, tagID := range append(append([]string{}, addTagIDs...), removeTagIDs...) { + if _, err := scanTag(tx.QueryRowContext(ctx, `SELECT id, name, color FROM tags WHERE id = ?`, tagID)); err != nil { + return err + } + } + for _, nodeID := range nodeIDs { + if nodeID == RootID || nodeID == UnsortedID { + return ErrConflict + } + if _, err := getWith(ctx, tx, nodeID); err != nil { + return err + } + if setType { + if _, err := tx.ExecContext(ctx, `UPDATE nodes SET type_id = ? WHERE id = ?`, typeID, nodeID); err != nil { + return err + } + } + for _, tagID := range addTagIDs { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO node_tags (node_id, tag_id) VALUES (?, ?)`, nodeID, tagID); err != nil { + return err + } + } + for _, tagID := range removeTagIDs { + if _, err := tx.ExecContext(ctx, `DELETE FROM node_tags WHERE node_id = ? AND tag_id = ?`, nodeID, tagID); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (s *Store) loadTagIDs(ctx context.Context, node *Node) error { + rows, err := s.db.QueryContext(ctx, `SELECT tag_id FROM node_tags WHERE node_id = ? ORDER BY tag_id`, node.ID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + node.TagIDs = append(node.TagIDs, id) + } + return rows.Err() +} + +func (s *Store) loadTreeTagIDs(ctx context.Context, byID map[string]*TreeNode) error { + rows, err := s.db.QueryContext(ctx, `SELECT node_id, tag_id FROM node_tags ORDER BY tag_id`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var nodeID, tagID string + if err := rows.Scan(&nodeID, &tagID); err != nil { + return err + } + if node := byID[nodeID]; node != nil { + node.TagIDs = append(node.TagIDs, tagID) + } + } + return rows.Err() +} + +func scanNodeType(row scanner) (NodeType, error) { + var value NodeType + var description, iconKey, color sql.NullString + if err := row.Scan(&value.ID, &value.Name, &description, &iconKey, &color); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return NodeType{}, ErrNotFound + } + return NodeType{}, err + } + if description.Valid { + value.Description = &description.String + } + if iconKey.Valid { + value.IconKey = &iconKey.String + } + if color.Valid { + value.Color = &color.String + } + return value, nil +} + +func scanTag(row scanner) (Tag, error) { + var value Tag + var color sql.NullString + if err := row.Scan(&value.ID, &value.Name, &color); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Tag{}, ErrNotFound + } + return Tag{}, err + } + if color.Valid { + value.Color = &color.String + } + return value, nil +} + +func hasDuplicateIDsAllowEmpty(ids []string) bool { + if len(ids) == 0 { + return false + } + return hasDuplicateIDs(ids) +} diff --git a/server/internal/tree/metadata_test.go b/server/internal/tree/metadata_test.go new file mode 100644 index 0000000..3851aeb --- /dev/null +++ b/server/internal/tree/metadata_test.go @@ -0,0 +1,94 @@ +package tree + +import ( + "context" + "errors" + "testing" +) + +func TestTypeAndTagLifecycleAndClassification(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + node := createTestNode(t, store, UnsortedID, "Box") + typeValue, err := store.CreateNodeType(ctx, "Storage bin", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + tag, err := store.CreateTag(ctx, "Fragile", nil) + if err != nil { + t.Fatal(err) + } + classified, err := store.SetClassification(ctx, node.ID, &typeValue.ID, []string{tag.ID}) + if err != nil { + t.Fatal(err) + } + if classified.TypeID == nil || *classified.TypeID != typeValue.ID || len(classified.TagIDs) != 1 || classified.TagIDs[0] != tag.ID { + t.Fatalf("classification missing: %#v", classified) + } + treeValue, err := store.Tree(ctx) + if err != nil { + t.Fatal(err) + } + fromTree := treeValue.findForTest(node.ID) + if fromTree == nil || fromTree.TypeID == nil || len(fromTree.TagIDs) != 1 { + t.Fatalf("tree classification missing: %#v", fromTree) + } + if err := store.DeleteNodeType(ctx, typeValue.ID); err != nil { + t.Fatal(err) + } + if err := store.DeleteTag(ctx, tag.ID); err != nil { + t.Fatal(err) + } + after := mustGet(t, store, node.ID) + if after.TypeID != nil || len(after.TagIDs) != 0 { + t.Fatalf("deleting metadata damaged assignments: %#v", after) + } +} + +func TestBulkClassificationIsAtomic(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + first := createTestNode(t, store, UnsortedID, "First") + second := createTestNode(t, store, UnsortedID, "Second") + typeValue, _ := store.CreateNodeType(ctx, "Box", nil, nil, nil) + tag, _ := store.CreateTag(ctx, "Blue", nil) + if err := store.ClassifyMany(ctx, []string{first.ID, second.ID}, true, &typeValue.ID, []string{tag.ID}, nil); err != nil { + t.Fatal(err) + } + for _, id := range []string{first.ID, second.ID} { + node := mustGet(t, store, id) + if node.TypeID == nil || len(node.TagIDs) != 1 { + t.Fatalf("bulk assignment missing: %#v", node) + } + } + if err := store.ClassifyMany(ctx, []string{first.ID, "missing"}, false, nil, nil, []string{tag.ID}); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected not found, got %v", err) + } + if len(mustGet(t, store, first.ID).TagIDs) != 1 { + t.Fatal("failed bulk request partially changed nodes") + } +} + +func TestIndividualizeInheritsTypeAndTags(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + quantity := int64(2) + node, err := store.Create(ctx, nil, "Bin", nil, &quantity) + if err != nil { + t.Fatal(err) + } + typeValue, _ := store.CreateNodeType(ctx, "Bin", nil, nil, nil) + tag, _ := store.CreateTag(ctx, "Blue", nil) + if _, err := store.SetClassification(ctx, node.ID, &typeValue.ID, []string{tag.ID}); err != nil { + t.Fatal(err) + } + units, err := store.Individualize(ctx, node.ID, []string{"Bin 01", "Bin 02"}) + if err != nil { + t.Fatal(err) + } + for _, unit := range units { + if unit.TypeID == nil || *unit.TypeID != typeValue.ID || len(unit.TagIDs) != 1 || unit.TagIDs[0] != tag.ID { + t.Fatalf("metadata not inherited: %#v", unit) + } + } +} diff --git a/server/internal/tree/store.go b/server/internal/tree/store.go index 8442744..a186fdd 100644 --- a/server/internal/tree/store.go +++ b/server/internal/tree/store.go @@ -23,11 +23,14 @@ var ( ) type Node struct { - ID string `json:"id"` - LookupCode string `json:"lookupCode"` - ParentID *string `json:"parentId"` - Name string `json:"name"` - Quantity *int64 `json:"quantity"` + ID string `json:"id"` + LookupCode string `json:"lookupCode"` + ParentID *string `json:"parentId"` + Name string `json:"name"` + Description *string `json:"description"` + Quantity *int64 `json:"quantity"` + TypeID *string `json:"typeId"` + TagIDs []string `json:"tagIds"` } type TreeNode struct { @@ -36,9 +39,10 @@ type TreeNode struct { } type ImportNode struct { - Name string `json:"name"` - Quantity *int64 `json:"quantity,omitempty"` - Children []ImportNode `json:"children,omitempty"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Quantity *int64 `json:"quantity,omitempty"` + Children []ImportNode `json:"children,omitempty"` } type ChildHandling string @@ -77,12 +81,31 @@ func (s *Store) Initialize(ctx context.Context) error { return err } defer tx.Rollback() + if _, err = tx.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS node_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK (length(trim(name)) > 0), + description TEXT, + icon_key TEXT, + color TEXT + )`); err != nil { + return fmt.Errorf("create node types table: %w", err) + } + if _, err = tx.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE UNIQUE CHECK (length(trim(name)) > 0), + color TEXT + )`); err != nil { + return fmt.Errorf("create tags table: %w", err) + } if _, err = tx.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS nodes ( id TEXT PRIMARY KEY, parent_id TEXT REFERENCES nodes(id) ON DELETE RESTRICT, name TEXT NOT NULL CHECK (length(trim(name)) > 0), + description TEXT, quantity INTEGER CHECK (quantity IS NULL OR quantity >= 0), system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0, 1)), CHECK (id <> parent_id) @@ -113,6 +136,24 @@ func (s *Store) Initialize(ctx context.Context) error { return fmt.Errorf("add lookup code: %w", err) } } + if !columns["description"] { + if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN description TEXT`); err != nil { + return fmt.Errorf("add description: %w", err) + } + } + if !columns["type_id"] { + if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN type_id TEXT REFERENCES node_types(id) ON DELETE SET NULL`); err != nil { + return fmt.Errorf("add node type: %w", err) + } + } + if _, err = tx.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS node_tags ( + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (node_id, tag_id) + )`); err != nil { + return fmt.Errorf("create node tags table: %w", err) + } rows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE lookup_code IS NULL`) if err != nil { return err @@ -144,8 +185,9 @@ func (s *Store) Initialize(ctx context.Context) error { return tx.Commit() } -func (s *Store) Create(ctx context.Context, parentID *string, name string, quantity *int64) (Node, error) { +func (s *Store) Create(ctx context.Context, parentID *string, name string, description *string, quantity *int64) (Node, error) { name = strings.TrimSpace(name) + description = cleanOptionalString(description) if name == "" || quantity != nil && *quantity < 0 { return Node{}, ErrInvalid } @@ -166,19 +208,20 @@ func (s *Store) Create(ctx context.Context, parentID *string, name string, quant return Node{}, fmt.Errorf("generate lookup code: %w", err) } if _, err = s.db.ExecContext(ctx, - `INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`, - id, code, parent, name, quantity, + `INSERT INTO nodes (id, lookup_code, parent_id, name, description, quantity) VALUES (?, ?, ?, ?, ?, ?)`, + id, code, parent, name, description, quantity, ); err != nil { return Node{}, fmt.Errorf("create node: %w", err) } return s.Get(ctx, id) } -func (s *Store) CreateMany(ctx context.Context, parentID string, names []string) ([]Node, error) { +func (s *Store) CreateMany(ctx context.Context, parentID string, names []string, description *string) ([]Node, error) { if len(names) == 0 { return nil, ErrInvalid } cleanNames := make([]string, len(names)) + description = cleanOptionalString(description) ids := make([]string, len(names)) for index, name := range names { cleanNames[index] = strings.TrimSpace(name) @@ -206,8 +249,8 @@ func (s *Store) CreateMany(ctx context.Context, parentID string, names []string) return nil, err } if _, err := tx.ExecContext(ctx, - `INSERT INTO nodes (id, lookup_code, parent_id, name) VALUES (?, ?, ?, ?)`, - ids[index], code, parentID, cleanNames[index], + `INSERT INTO nodes (id, lookup_code, parent_id, name, description) VALUES (?, ?, ?, ?, ?)`, + ids[index], code, parentID, cleanNames[index], description, ); err != nil { return nil, fmt.Errorf("create node: %w", err) } @@ -256,8 +299,8 @@ func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportN return err } if _, err := tx.ExecContext(ctx, - `INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`, - id, code, parent, name, child.Quantity, + `INSERT INTO nodes (id, lookup_code, parent_id, name, description, quantity) VALUES (?, ?, ?, ?, ?, ?)`, + id, code, parent, name, cleanOptionalString(child.Description), child.Quantity, ); err != nil { return err } @@ -278,21 +321,35 @@ func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportN } func (s *Store) Get(ctx context.Context, id string) (Node, error) { - return scanNode(s.db.QueryRowContext(ctx, - `SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id, + node, err := scanNode(s.db.QueryRowContext(ctx, + `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id, )) + if err != nil { + return Node{}, err + } + if err := s.loadTagIDs(ctx, &node); err != nil { + return Node{}, err + } + return node, nil } func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) { - return scanNode(s.db.QueryRowContext(ctx, - `SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE lookup_code = ?`, + node, err := scanNode(s.db.QueryRowContext(ctx, + `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE lookup_code = ?`, strings.ToUpper(strings.TrimSpace(code)), )) + if err != nil { + return Node{}, err + } + if err := s.loadTagIDs(ctx, &node); err != nil { + return Node{}, err + } + return node, nil } func (s *Store) Tree(ctx context.Context) (*TreeNode, error) { rows, err := s.db.QueryContext(ctx, - `SELECT id, lookup_code, parent_id, name, quantity FROM nodes ORDER BY rowid`) + `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes ORDER BY rowid`) if err != nil { return nil, err } @@ -312,6 +369,9 @@ func (s *Store) Tree(ctx context.Context) (*TreeNode, error) { if err := rows.Err(); err != nil { return nil, err } + if err := rows.Close(); err != nil { + return nil, err + } root := byID[RootID] if root == nil { @@ -326,11 +386,14 @@ func (s *Store) Tree(ctx context.Context) (*TreeNode, error) { } byID[*node.ParentID].Children = append(byID[*node.ParentID].Children, node) } + if err := s.loadTreeTagIDs(ctx, byID); err != nil { + return nil, err + } return root, nil } -func (s *Store) Update(ctx context.Context, id string, name *string, quantity QuantityUpdate) (Node, error) { - if name == nil && !quantity.Set { +func (s *Store) Update(ctx context.Context, id string, name *string, description StringUpdate, quantity QuantityUpdate) (Node, error) { + if name == nil && !description.Set && !quantity.Set { return Node{}, ErrInvalid } if name != nil { @@ -346,6 +409,9 @@ func (s *Store) Update(ctx context.Context, id string, name *string, quantity Qu if quantity.Set && quantity.Value != nil && *quantity.Value < 0 { return Node{}, ErrInvalid } + if description.Set { + description.Value = cleanOptionalString(description.Value) + } if _, err := s.Get(ctx, id); err != nil { return Node{}, err } @@ -359,6 +425,11 @@ func (s *Store) Update(ctx context.Context, id string, name *string, quantity Qu return Node{}, err } } + if description.Set { + if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET description = ? WHERE id = ?`, description.Value, id); err != nil { + return Node{}, err + } + } return s.Get(ctx, id) } @@ -421,6 +492,158 @@ func (s *Store) DeleteMany(ctx context.Context, ids []string) error { return tx.Commit() } +// Individualize turns one empty quantity-tracked node into separately identified +// sibling nodes. The original node is retained as the first unit so its lookup +// identity remains valid. +func (s *Store) Individualize(ctx context.Context, id string, names []string) ([]Node, error) { + if id == RootID || id == UnsortedID || len(names) < 2 || len(names) > 1000 { + return nil, ErrInvalid + } + cleanNames := make([]string, len(names)) + for index, name := range names { + cleanNames[index] = strings.TrimSpace(name) + if cleanNames[index] == "" { + return nil, ErrInvalid + } + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + node, err := getWith(ctx, tx, id) + if err != nil { + return nil, err + } + if node.Quantity == nil || *node.Quantity < 2 || int64(len(names)) != *node.Quantity || node.ParentID == nil { + return nil, ErrConflict + } + var hasChildren int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE parent_id = ?)`, id).Scan(&hasChildren); err != nil { + return nil, err + } + if hasChildren == 1 { + return nil, ErrConflict + } + tagRows, err := tx.QueryContext(ctx, `SELECT tag_id FROM node_tags WHERE node_id = ?`, id) + if err != nil { + return nil, err + } + var inheritedTagIDs []string + for tagRows.Next() { + var tagID string + if err := tagRows.Scan(&tagID); err != nil { + tagRows.Close() + return nil, err + } + inheritedTagIDs = append(inheritedTagIDs, tagID) + } + if err := tagRows.Close(); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE nodes SET name = ?, quantity = NULL WHERE id = ?`, cleanNames[0], id); err != nil { + return nil, err + } + ids := []string{id} + for _, name := range cleanNames[1:] { + newNodeID, err := newID() + if err != nil { + return nil, err + } + code, err := newLookupCode(ctx, tx) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO nodes (id, lookup_code, parent_id, name, description, type_id) VALUES (?, ?, ?, ?, ?, ?)`, newNodeID, code, *node.ParentID, name, node.Description, node.TypeID); err != nil { + return nil, err + } + for _, tagID := range inheritedTagIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO node_tags (node_id, tag_id) VALUES (?, ?)`, newNodeID, tagID); err != nil { + return nil, err + } + } + ids = append(ids, newNodeID) + } + if err := tx.Commit(); err != nil { + return nil, err + } + result := make([]Node, len(ids)) + for index, nodeID := range ids { + result[index], err = s.Get(ctx, nodeID) + if err != nil { + return nil, err + } + } + return result, nil +} + +// Combine replaces empty sibling nodes with one quantity-tracked node. The +// retained node keeps its UUID and lookup code; every other selected identity +// is removed. +func (s *Store) Combine(ctx context.Context, ids []string, retainedID, name string) (Node, error) { + name = strings.TrimSpace(name) + if len(ids) < 2 || hasDuplicateIDs(ids) || name == "" { + return Node{}, ErrInvalid + } + selected := make(map[string]bool, len(ids)) + for _, id := range ids { + if id == RootID || id == UnsortedID { + return Node{}, ErrConflict + } + selected[id] = true + } + if !selected[retainedID] { + return Node{}, ErrInvalid + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Node{}, err + } + defer tx.Rollback() + var parentID string + var quantity int64 + for index, id := range ids { + node, err := getWith(ctx, tx, id) + if err != nil { + return Node{}, err + } + if node.ParentID == nil { + return Node{}, ErrConflict + } + if index == 0 { + parentID = *node.ParentID + } else if *node.ParentID != parentID { + return Node{}, ErrConflict + } + var hasChildren int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE parent_id = ?)`, id).Scan(&hasChildren); err != nil { + return Node{}, err + } + if hasChildren == 1 { + return Node{}, ErrConflict + } + if node.Quantity == nil { + quantity++ + } else { + quantity += *node.Quantity + } + } + if _, err := tx.ExecContext(ctx, `UPDATE nodes SET name = ?, quantity = ? WHERE id = ?`, name, quantity, retainedID); err != nil { + return Node{}, err + } + for _, id := range ids { + if id != retainedID { + if _, err := tx.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id); err != nil { + return Node{}, err + } + } + } + if err := tx.Commit(); err != nil { + return Node{}, err + } + return s.Get(ctx, retainedID) +} + func (s *Store) Move(ctx context.Context, id, targetParentID string, handling ChildHandling) (Node, error) { if handling == "" { handling = WithSubtree @@ -557,8 +780,10 @@ type scanner interface { func scanNode(row scanner) (Node, error) { var node Node var parent sql.NullString + var description sql.NullString var quantity sql.NullInt64 - if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &quantity); err != nil { + var typeID sql.NullString + if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &description, &quantity, &typeID); err != nil { if errors.Is(err, sql.ErrNoRows) { return Node{}, ErrNotFound } @@ -570,12 +795,19 @@ func scanNode(row scanner) (Node, error) { if quantity.Valid { node.Quantity = &quantity.Int64 } + if description.Valid { + node.Description = &description.String + } + if typeID.Valid { + node.TypeID = &typeID.String + } + node.TagIDs = []string{} return node, nil } func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) { return scanNode(tx.QueryRowContext(ctx, - `SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id)) + `SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id)) } func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) { @@ -653,3 +885,19 @@ func hasDuplicateIDs(ids []string) bool { } return false } + +type StringUpdate struct { + Set bool + Value *string +} + +func cleanOptionalString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/server/internal/tree/store_test.go b/server/internal/tree/store_test.go index 39c76b1..72f76f9 100644 --- a/server/internal/tree/store_test.go +++ b/server/internal/tree/store_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "testing" @@ -61,7 +62,7 @@ func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) { node := createTestNode(t, store, RootID, "Box") code := node.LookupCode newName := "Renamed box" - if _, err := store.Update(ctx, node.ID, &newName, QuantityUpdate{}); err != nil { + if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}); err != nil { t.Fatal(err) } if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil { @@ -162,7 +163,7 @@ func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) { if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) { t.Errorf("move %s: expected conflict, got %v", id, err) } - if _, err := store.Update(ctx, id, &newName, QuantityUpdate{}); !errors.Is(err, ErrConflict) { + if _, err := store.Update(ctx, id, &newName, StringUpdate{}, QuantityUpdate{}); !errors.Is(err, ErrConflict) { t.Errorf("rename %s: expected conflict, got %v", id, err) } } @@ -172,11 +173,11 @@ func TestCreateDefaultsToUnsortedAndAcceptsDuplicateNames(t *testing.T) { ctx := context.Background() store := newTestStore(t) quantity := int64(0) - first, err := store.Create(ctx, nil, "Pens", &quantity) + first, err := store.Create(ctx, nil, "Pens", nil, &quantity) if err != nil { t.Fatal(err) } - second, err := store.Create(ctx, nil, "Pens", nil) + second, err := store.Create(ctx, nil, "Pens", nil, nil) if err != nil { t.Fatal(err) } @@ -192,7 +193,7 @@ func TestCreateManyIsOrderedAndAtomic(t *testing.T) { store := newTestStore(t) parent := createTestNode(t, store, RootID, "parent") - created, err := store.CreateMany(ctx, parent.ID, []string{" Hammer ", "Hammer", "Screws"}) + created, err := store.CreateMany(ctx, parent.ID, []string{" Hammer ", "Hammer", "Screws"}, nil) if err != nil { t.Fatal(err) } @@ -200,7 +201,7 @@ func TestCreateManyIsOrderedAndAtomic(t *testing.T) { t.Fatalf("unexpected created nodes: %#v", created) } - if _, err := store.CreateMany(ctx, parent.ID, []string{"Valid", " "}); !errors.Is(err, ErrInvalid) { + if _, err := store.CreateMany(ctx, parent.ID, []string{"Valid", " "}, nil); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid bulk request, got %v", err) } tree, err := store.Tree(ctx) @@ -266,6 +267,83 @@ func TestDeleteManyIsAtomicWhenAnyNodeIsNotEmpty(t *testing.T) { } } +func TestIndividualizeRetainsFirstIdentityAndCreatesUniqueUnits(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + quantity := int64(3) + original, err := store.Create(ctx, nil, "Bin", nil, &quantity) + if err != nil { + t.Fatal(err) + } + units, err := store.Individualize(ctx, original.ID, []string{"Bin 01", "Bin 02", "Bin 03"}) + if err != nil { + t.Fatal(err) + } + if len(units) != 3 || units[0].ID != original.ID || units[0].LookupCode != original.LookupCode { + t.Fatalf("original identity was not retained: %#v", units) + } + codes := map[string]bool{} + for index, unit := range units { + if unit.Quantity != nil || unit.Name != fmt.Sprintf("Bin %02d", index+1) || codes[unit.LookupCode] { + t.Fatalf("unexpected individualized unit: %#v", unit) + } + codes[unit.LookupCode] = true + } +} + +func TestIndividualizeRejectsNonEmptyNodeWithoutChanges(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + quantity := int64(2) + original, err := store.Create(ctx, nil, "Bin", nil, &quantity) + if err != nil { + t.Fatal(err) + } + createTestNode(t, store, original.ID, "contents") + if _, err := store.Individualize(ctx, original.ID, []string{"Bin 01", "Bin 02"}); !errors.Is(err, ErrConflict) { + t.Fatalf("expected conflict, got %v", err) + } + unchanged := mustGet(t, store, original.ID) + if unchanged.Quantity == nil || *unchanged.Quantity != 2 || unchanged.Name != "Bin" { + t.Fatalf("node was partially changed: %#v", unchanged) + } +} + +func TestCombineEmptySiblingsSumsQuantityAndRetainsIdentity(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + first := createTestNode(t, store, UnsortedID, "Bin 01") + quantity := int64(3) + second, err := store.Create(ctx, nil, "Bins", nil, &quantity) + if err != nil { + t.Fatal(err) + } + combined, err := store.Combine(ctx, []string{first.ID, second.ID}, first.ID, "Bins") + if err != nil { + t.Fatal(err) + } + if combined.ID != first.ID || combined.LookupCode != first.LookupCode || combined.Quantity == nil || *combined.Quantity != 4 || combined.Name != "Bins" { + t.Fatalf("unexpected combined node: %#v", combined) + } + if _, err := store.Get(ctx, second.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("combined identity was not removed: %v", err) + } +} + +func TestCombineRejectsDifferentParentsWithoutChanges(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + parent := createTestNode(t, store, RootID, "parent") + first := createTestNode(t, store, UnsortedID, "first") + second := createTestNode(t, store, parent.ID, "second") + if _, err := store.Combine(ctx, []string{first.ID, second.ID}, first.ID, "combined"); !errors.Is(err, ErrConflict) { + t.Fatalf("expected conflict, got %v", err) + } + if _, err := store.Get(ctx, second.ID); err != nil { + t.Fatalf("node was partially deleted: %v", err) + } +} + func newTestStore(t *testing.T) *Store { t.Helper() db, err := sql.Open("sqlite", ":memory:") @@ -282,7 +360,7 @@ func newTestStore(t *testing.T) *Store { func createTestNode(t *testing.T, store *Store, parentID, name string) Node { t.Helper() - node, err := store.Create(context.Background(), &parentID, name, nil) + node, err := store.Create(context.Background(), &parentID, name, nil, nil) if err != nil { t.Fatal(err) } diff --git a/web/app.js b/web/app.js deleted file mode 100644 index ee0cc02..0000000 --- a/web/app.js +++ /dev/null @@ -1,338 +0,0 @@ -const state = { - root: null, - nodes: new Map(), - selectedId: null, - importPreview: null, -}; - -const elements = { - status: document.querySelector("#status"), - tree: document.querySelector("#tree"), - selected: document.querySelector("#selected"), - refresh: document.querySelector("#refresh"), - createForm: document.querySelector("#create-form"), - createParent: document.querySelector("#create-parent"), - createName: document.querySelector("#create-name"), - createQuantity: document.querySelector("#create-quantity"), - updateForm: document.querySelector("#update-form"), - updateName: document.querySelector("#update-name"), - updateQuantity: document.querySelector("#update-quantity"), - moveForm: document.querySelector("#move-form"), - moveParent: document.querySelector("#move-parent"), - moveChildren: document.querySelector("#move-children"), - deleteButton: document.querySelector("#delete"), - importForm: document.querySelector("#import-form"), - importParent: document.querySelector("#import-parent"), - importManifest: document.querySelector("#import-manifest"), - importPreview: document.querySelector("#import-preview"), - importSummary: document.querySelector("#import-summary"), - importWarnings: document.querySelector("#import-warnings"), - importNormalized: document.querySelector("#import-normalized"), - importCommit: document.querySelector("#import-commit"), - importCancel: document.querySelector("#import-cancel"), -}; - -async function api(path, options = {}) { - const response = await fetch(path, { - ...options, - headers: options.body ? { "Content-Type": "application/json" } : undefined, - }); - if (response.status === 204) { - return null; - } - const body = await response.json(); - if (!response.ok) { - throw new Error( - body.message || `Request failed with status ${response.status}`, - ); - } - return body; -} - -async function loadTree() { - try { - state.root = await api("/api/tree"); - state.nodes.clear(); - indexNode(state.root); - const requestedCode = new URLSearchParams(window.location.search) - .get("code") - ?.toUpperCase(); - if (requestedCode) { - state.selectedId = - [...state.nodes.values()].find( - (node) => node.lookupCode === requestedCode, - )?.id || null; - } - if (state.selectedId && !state.nodes.has(state.selectedId)) { - state.selectedId = null; - } - render(); - setStatus(""); - } catch (error) { - setStatus(error.message); - } -} - -function indexNode(node) { - state.nodes.set(node.id, node); - for (const child of node.children) { - indexNode(child); - } -} - -function render() { - elements.tree.replaceChildren(renderNode(state.root)); - renderSelection(); - renderImportParents(); - renderImportPreview(); -} - -function renderImportParents() { - const previous = - elements.importParent.value || state.selectedId || "unsorted"; - elements.importParent.replaceChildren(); - for (const node of state.nodes.values()) { - const option = document.createElement("option"); - option.value = node.id; - option.textContent = nodePath(node) - .map((part) => part.name) - .join(" / "); - elements.importParent.append(option); - } - elements.importParent.value = state.nodes.has(previous) - ? previous - : "unsorted"; -} - -function nodePath(node) { - const path = [node]; - let current = node; - while (current.parentId) { - current = state.nodes.get(current.parentId); - if (!current) break; - path.unshift(current); - } - return path; -} - -function renderImportPreview() { - const preview = state.importPreview; - elements.importPreview.hidden = !preview; - elements.importCommit.disabled = !preview; - if (!preview) { - elements.importSummary.textContent = ""; - elements.importWarnings.replaceChildren(); - elements.importNormalized.textContent = ""; - return; - } - elements.importSummary.textContent = `${preview.summary.nodes} nodes, maximum depth ${preview.summary.maximumDepth}`; - elements.importWarnings.replaceChildren(); - for (const warning of preview.warnings) { - const item = document.createElement("li"); - item.textContent = warning; - elements.importWarnings.append(item); - } - elements.importNormalized.replaceChildren( - renderImportNodes(preview.manifest.nodes), - ); -} - -function renderImportNodes(nodes) { - const list = document.createElement("ul"); - for (const node of nodes) { - const item = document.createElement("li"); - const label = document.createElement("strong"); - label.textContent = node.name; - item.append(label); - if (node.quantity !== undefined && node.quantity !== null) { - item.append(` — quantity ${node.quantity}`); - } - if (node.children?.length) { - item.append(renderImportNodes(node.children)); - } - list.append(item); - } - return list; -} - -function renderNode(node) { - const list = document.createElement("ul"); - const item = document.createElement("li"); - const button = document.createElement("button"); - button.type = "button"; - button.textContent = - node.quantity === null ? node.name : `${node.name} (${node.quantity})`; - button.addEventListener("click", () => { - state.selectedId = node.id; - renderSelection(); - }); - item.append(button); - if (node.children.length > 0) { - for (const child of node.children) { - item.append(renderNode(child)); - } - } - list.append(item); - return list; -} - -function renderSelection() { - const node = state.nodes.get(state.selectedId); - const hasSelection = Boolean(node); - const protectedNode = node?.id === "root" || node?.id === "unsorted"; - - elements.selected.textContent = node - ? `${node.name} [${node.lookupCode}]` - : "None"; - elements.createParent.textContent = `Parent: ${node?.name || "Unsorted"}`; - elements.updateName.value = node?.name || ""; - elements.updateQuantity.value = node?.quantity ?? ""; - - for (const control of elements.updateForm.elements) { - control.disabled = !hasSelection || protectedNode; - } - for (const control of elements.moveForm.elements) { - control.disabled = !hasSelection || protectedNode; - } - elements.deleteButton.disabled = !hasSelection || protectedNode; - - elements.moveParent.replaceChildren(); - for (const candidate of state.nodes.values()) { - if (candidate.id === state.selectedId) { - continue; - } - const option = document.createElement("option"); - option.value = candidate.id; - option.textContent = candidate.name; - elements.moveParent.append(option); - } -} - -function quantityFrom(input) { - return input.value === "" ? null : Number(input.value); -} - -elements.refresh.addEventListener("click", loadTree); - -elements.createForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const request = { - name: elements.createName.value, - quantity: quantityFrom(elements.createQuantity), - }; - if (state.selectedId) { - request.parentId = state.selectedId; - } - await perform(async () => { - const node = await api("/api/nodes", { - method: "POST", - body: JSON.stringify(request), - }); - state.selectedId = node.id; - elements.createForm.reset(); - }); -}); - -elements.updateForm.addEventListener("submit", async (event) => { - event.preventDefault(); - await perform(() => - api(`/api/nodes/${state.selectedId}`, { - method: "PATCH", - body: JSON.stringify({ - name: elements.updateName.value, - quantity: quantityFrom(elements.updateQuantity), - }), - }), - ); -}); - -elements.moveForm.addEventListener("submit", async (event) => { - event.preventDefault(); - await perform(() => - api(`/api/nodes/${state.selectedId}/move`, { - method: "POST", - body: JSON.stringify({ - targetParentId: elements.moveParent.value, - childHandling: elements.moveChildren.value, - }), - }), - ); -}); - -elements.deleteButton.addEventListener("click", async () => { - const node = state.nodes.get(state.selectedId); - if (!node || !window.confirm(`Delete ${node.name}?`)) { - return; - } - await perform(async () => { - await api(`/api/nodes/${node.id}`, { method: "DELETE" }); - state.selectedId = null; - }); -}); - -elements.importForm.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - setStatus(""); - const manifest = JSON.parse(elements.importManifest.value); - state.importPreview = await api("/api/imports/tree/preview", { - method: "POST", - body: JSON.stringify({ - parentId: elements.importParent.value, - manifest, - }), - }); - renderImportPreview(); - } catch (error) { - state.importPreview = null; - renderImportPreview(); - setStatus( - error instanceof SyntaxError - ? `Invalid JSON: ${error.message}` - : error.message, - ); - } -}); - -elements.importManifest.addEventListener("input", clearImportPreview); -elements.importParent.addEventListener("change", clearImportPreview); -elements.importCancel.addEventListener("click", clearImportPreview); - -elements.importCommit.addEventListener("click", async () => { - const preview = state.importPreview; - if (!preview) return; - try { - setStatus(""); - const result = await api("/api/imports/tree/commit", { - method: "POST", - body: JSON.stringify({ planId: preview.planId }), - }); - state.importPreview = null; - elements.importManifest.value = ""; - await loadTree(); - setStatus(`Imported ${result.created.nodes} nodes`); - } catch (error) { - setStatus(error.message); - } -}); - -function clearImportPreview() { - state.importPreview = null; - renderImportPreview(); -} - -async function perform(action) { - try { - setStatus(""); - await action(); - await loadTree(); - } catch (error) { - setStatus(error.message); - } -} - -function setStatus(message) { - elements.status.textContent = message; -} - -loadTree(); diff --git a/web/index.html b/web/index.html index 0d838c0..8b894e8 100644 --- a/web/index.html +++ b/web/index.html @@ -1,103 +1,14 @@ - - + + + + Box Manifest - -

Box Manifest

-

- -
-

Tree

- -
Loading…
-
- -
-

Selected node

-

None

- -
-

Update

- - - -
- -
-

Move

- - - -
- - -
- -
-
-

Create child

-

Parent: Unsorted

- - - -
-
- -
-
-

Import tree

- - - -
- - -
+
+ diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..7a8f51c --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1878 @@ +{ + "name": "box-manifest-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "box-manifest-web", + "version": "0.0.0", + "dependencies": { + "@fontsource/inter": "^5.3.0", + "jspdf": "^4.2.1", + "qrcode": "^1.5.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8064b7c --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "box-manifest-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit --pretty false", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/inter": "^5.3.0", + "jspdf": "^4.2.1", + "qrcode": "^1.5.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0cb6b70 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,719 @@ +import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ApiNode, ImportNode, ImportPreview, NodeType, Tag, request } from "./api"; +import { BoxIcon, CheckIcon, ChevronIcon, CloseIcon, DownloadIcon, EditSquareIcon, ExportIcon, FilterIcon, FilterPlusIcon, FolderIcon, ImportIcon, InventoryIcon, MoreIcon, PlusIcon, SearchIcon, TypeIcon } from "./icons"; +import { LabelEntry, LabelSheetDialog } from "./LabelSheetDialog"; + +type View = "inventory" | "import" | "export" | "metadata"; +type ChildHandling = "WITH_SUBTREE" | "PROMOTE_CHILDREN" | "CHILDREN_TO_UNSORTED"; + +interface FlatNode { node: ApiNode; depth: number } +interface PendingDrop { nodeIds: string[]; targetParentId: string } +interface TreeDragProps { + draggedIds: Set; + dragOverId: string | null; + canDropOn: (id: string) => boolean; + onDragStart: (id: string, event: React.DragEvent) => void; + onDragOver: (id: string | null) => void; + onDragEnd: () => void; + onDrop: (id: string) => void; +} + +function flatten(root: ApiNode): FlatNode[] { + const result: FlatNode[] = []; + const visit = (node: ApiNode, depth: number) => { + result.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return result; +} + +function descendants(node: ApiNode): Set { + const ids = new Set([node.id]); + node.children.forEach((child) => descendants(child).forEach((id) => ids.add(id))); + return ids; +} + +function findPath(root: ApiNode, id: string): ApiNode[] { + if (root.id === id) return [root]; + for (const child of root.children) { + const path = findPath(child, id); + if (path.length) return [root, ...path]; + } + return []; +} + +export function App() { + const [root, setRoot] = useState(null); + const [nodeTypes, setNodeTypes] = useState([]); + const [tags, setTags] = useState([]); + const [currentLocationId, setCurrentLocationId] = useState("root"); + const [locationBackStack, setLocationBackStack] = useState([]); + const [locationForwardStack, setLocationForwardStack] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState("inventory"); + const [searchOpen, setSearchOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectionAnchor, setSelectionAnchor] = useState(null); + const [draggedIds, setDraggedIds] = useState>(new Set()); + const [dragOverId, setDragOverId] = useState(null); + const [pendingDrop, setPendingDrop] = useState(null); + const [dialog, setDialog] = useState<"create" | "move" | "delete" | "bulkMove" | "bulkDelete" | "bulkClassify" | "combine" | "individualize" | "label" | "description" | "type" | "tags" | null>(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [renamingId, setRenamingId] = useState(null); + const [status, setStatus] = useState(""); + const [busy, setBusy] = useState(false); + const breadcrumbsRef = useRef(null); + + const nodes = useMemo(() => root ? flatten(root) : [], [root]); + const byId = useMemo(() => new Map(nodes.map(({ node }) => [node.id, node])), [nodes]); + const selected = selectedId ? byId.get(selectedId) ?? null : null; + const currentLocation = byId.get(currentLocationId) ?? root; + const selectedNodes = nodes.map(({ node }) => node).filter((node) => selectedIds.has(node.id)); + const path = root ? findPath(root, currentLocationId) : []; + + const loadTree = useCallback(async () => { + try { + const [tree, typesResult, tagsResult] = await Promise.all([request("/api/tree"), request("/api/node-types"), request("/api/tags")]); + setRoot(tree); + setNodeTypes(typesResult); + setTags(tagsResult); + const code = new URLSearchParams(window.location.search).get("code")?.toUpperCase(); + if (code) { + const match = flatten(tree).find(({ node }) => node.lookupCode === code)?.node; + if (match) { setCurrentLocationId(match.parentId ?? "root"); setSelectedId(match.id); setSelectedIds(new Set([match.id])); } + } + setCurrentLocationId((current) => flatten(tree).some(({ node }) => node.id === current) ? current : "root"); + setSelectedId((current) => current && flatten(tree).some(({ node }) => node.id === current) ? current : null); + setSelectedIds((current) => new Set([...current].filter((id) => flatten(tree).some(({ node }) => node.id === id)))); + setStatus(""); + } catch (error) { + setStatus(messageOf(error)); + } + }, []); + + useEffect(() => { void loadTree(); }, [loadTree]); + useEffect(() => { + const frame = requestAnimationFrame(() => breadcrumbsRef.current?.scrollTo({ left: breadcrumbsRef.current.scrollWidth, behavior: "smooth" })); + return () => cancelAnimationFrame(frame); + }, [currentLocationId, view]); + useEffect(() => { const refreshOnFocus = () => void loadTree(); window.addEventListener("focus", refreshOnFocus); return () => window.removeEventListener("focus", refreshOnFocus); }, [loadTree]); + useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); window.addEventListener("blur", close); window.addEventListener("scroll", close, true); return () => { window.removeEventListener("blur", close); window.removeEventListener("scroll", close, true); }; }, [contextMenu]); + useEffect(() => { + const focusSearch = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener("keydown", focusSearch); + return () => window.removeEventListener("keydown", focusSearch); + }, []); + useEffect(() => { + const selectAllVisible = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "a" || view !== "inventory" || dialog || searchOpen || !root) return; + const target = event.target as HTMLElement | null; + if (target?.matches("input, textarea, select, [contenteditable='true']")) return; + event.preventDefault(); + const location = flatten(root).find(({ node }) => node.id === currentLocationId)?.node; + const visible = location?.children.filter((node) => node.id !== "root" && node.id !== "unsorted").map((node) => node.id) ?? []; + setSelectedIds(new Set(visible)); + setSelectionAnchor(visible[0] ?? null); + }; + window.addEventListener("keydown", selectAllVisible); + return () => window.removeEventListener("keydown", selectAllVisible); + }, [currentLocationId, dialog, root, searchOpen, view]); + + const mutate = async (action: () => Promise, success?: string) => { + setBusy(true); + setStatus(""); + try { + await action(); + await loadTree(); + if (success) setStatus(success); + setDialog(null); + } catch (error) { + setStatus(messageOf(error)); + } finally { + setBusy(false); + } + }; + + const createTag = async (name: string) => { + const color = TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)][1]; + const created = await request("/api/tags", { method: "POST", body: JSON.stringify({ name, color }) }); + setTags((current) => [...current, created].sort((left, right) => left.name.localeCompare(right.name))); + return created; + }; + const updateTag = async (tag: Tag) => { + const updated = await request(`/api/tags/${tag.id}`, { method: "PUT", body: JSON.stringify({ name: tag.name, color: tag.color }) }); + setTags((current) => current.map((item) => item.id === updated.id ? updated : item).sort((left, right) => left.name.localeCompare(right.name))); + return updated; + }; + const deleteTag = async (id: string) => { + await request(`/api/tags/${id}`, { method: "DELETE" }); + setTags((current) => current.filter((tag) => tag.id !== id)); + }; + + const selectNode = (id: string) => { + setSelectedId(id); + setView("inventory"); + }; + + const openNode = (id: string) => { + if (id !== currentLocationId) { + setLocationBackStack((previous) => [...previous, currentLocationId]); + setLocationForwardStack([]); + } + setCurrentLocationId(id); + setSelectedId(null); + setSelectedIds(new Set()); + setSelectionAnchor(null); + setView("inventory"); + }; + + const revealNode = (id: string) => { + const node = byId.get(id); + if (!node) return; + const destination = node.parentId ?? "root"; + if (destination !== currentLocationId) { + setLocationBackStack((previous) => [...previous, currentLocationId]); + setLocationForwardStack([]); + } + setCurrentLocationId(destination); + setSelectedId(id); + if (id !== "root" && id !== "unsorted") setSelectedIds(new Set([id])); + setSelectionAnchor(id); + setView("inventory"); + }; + + const clearLocationSelection = () => { + setSelectedId(null); + setSelectedIds(new Set()); + setSelectionAnchor(null); + }; + + const goBackLocation = () => { + const destination = locationBackStack.at(-1); + if (!destination) return; + setLocationBackStack((previous) => previous.slice(0, -1)); + setLocationForwardStack((previous) => [...previous, currentLocationId]); + setCurrentLocationId(destination); + clearLocationSelection(); + }; + + const goForwardLocation = () => { + const destination = locationForwardStack.at(-1); + if (!destination) return; + setLocationForwardStack((previous) => previous.slice(0, -1)); + setLocationBackStack((previous) => [...previous, currentLocationId]); + setCurrentLocationId(destination); + clearLocationSelection(); + }; + + useEffect(() => { + const navigateHistory = (event: KeyboardEvent) => { + if (!event.altKey || dialog || searchOpen) return; + if (event.key === "ArrowLeft" && locationBackStack.length > 0) { + event.preventDefault(); + goBackLocation(); + } else if (event.key === "ArrowRight" && locationForwardStack.length > 0) { + event.preventDefault(); + goForwardLocation(); + } + }; + window.addEventListener("keydown", navigateHistory); + return () => window.removeEventListener("keydown", navigateHistory); + }, [currentLocationId, dialog, locationBackStack, locationForwardStack, searchOpen]); + + const toggleSelected = (id: string) => { + setSelectedIds((previous) => { + const next = new Set(previous); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + setSelectedId(id); + setSelectionAnchor(id); + }; + + const selectGesture = (id: string, shiftKey: boolean, toggleKey: boolean) => { + if (!id) { clearLocationSelection(); return; } + if (shiftKey && root && selectionAnchor) { + const visible = currentLocation?.children.filter((node) => node.id !== "root" && node.id !== "unsorted").map((node) => node.id) ?? []; + const anchorIndex = visible.indexOf(selectionAnchor); + const targetIndex = visible.indexOf(id); + if (anchorIndex >= 0 && targetIndex >= 0) { + const range = visible.slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1); + setSelectedIds((previous) => new Set(toggleKey ? [...previous, ...range] : range)); + return; + } + } + if (toggleKey) { toggleSelected(id); setSelectedId(id); } else { + setSelectedIds(new Set([id])); + selectNode(id); + setSelectionAnchor(id); + } + }; + + const openContextMenu = (id: string, event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); + if (!selectedIds.has(id)) { setSelectedIds(new Set([id])); setSelectedId(id); setSelectionAnchor(id); } + setContextMenu({ x: event.clientX, y: event.clientY }); + }; + const openCurrentNodeMenu = (id: string, event: React.MouseEvent) => { + event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); + setSelectedIds(new Set([id])); setSelectedId(id); setSelectionAnchor(id); + setContextMenu({ x: rect.right - 8, y: rect.bottom + 4 }); + }; + + const beginDrag = (id: string, event: React.DragEvent) => { + const ids = selectedIds.has(id) ? [...selectedIds] : [id]; + const moving = ids.map((nodeId) => byId.get(nodeId)).filter((node): node is ApiNode => Boolean(node)); + if (moving.length > 1 && moving.some((node) => node.parentId !== moving[0].parentId)) { + event.preventDefault(); + setStatus("Selected nodes must share a parent to move together."); + return; + } + if (!selectedIds.has(id)) { + setSelectedIds(new Set([id])); + setSelectionAnchor(id); + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", ids.join(",")); + setDraggedIds(new Set(ids)); + setDragOverId(null); + }; + + const canDropOn = (targetId: string) => draggedIds.size > 0 && [...draggedIds].every((id) => { + const node = byId.get(id); + return node && !descendants(node).has(targetId); + }); + + const dropOn = (targetParentId: string) => { + if (!canDropOn(targetParentId)) return; + setPendingDrop({ nodeIds: [...draggedIds], targetParentId }); + setDragOverId(null); + }; + + return ( +
+ + +
+
+
+
+ {view !== "inventory" ? <>Inventory{view === "import" ? "Import" : view === "export" ? "Export" : "Types"} : <>{path.slice(1).map((node) => )}} +
+
+ + {view === "inventory" && } +
+
+ + {status &&
{status}
} + + {view === "import" ? ( + + ) : view === "export" ? ( + + ) : view === "metadata" ? ( + + ) : ( + { setRenamingId(null); void mutate(() => request(`/api/nodes/${node.id}`, { method: "PATCH", body: JSON.stringify({ name }) }), "Node renamed"); }} draggedIds={draggedIds} dragOverId={dragOverId} canDropOn={canDropOn} onDragStart={beginDrag} onDragOver={setDragOverId} onDragEnd={() => { setDraggedIds(new Set()); setDragOverId(null); }} onDrop={dropOn} onSelect={selectGesture} onOpen={openNode} /> + )} +
+ + {dialog === "create" && setDialog(null)} onCreate={(entries, description, quantity) => mutate(async () => { + const parentId = (currentLocation ?? byId.get("unsorted"))?.id; + if (entries.length > 1) { + const created = await request<{ nodes: ApiNode[] }>("/api/nodes/bulk", { method: "POST", body: JSON.stringify({ parentId, names: entries, description }) }); + setSelectedId(created.nodes.at(-1)?.id ?? null); + } else { + const created = await request("/api/nodes", { method: "POST", body: JSON.stringify({ parentId, name: entries[0], description, quantity }) }); + setSelectedId(created.id); + } + })} />} + {dialog === "move" && selected && setDialog(null)} onMove={(targetParentId, childHandling) => mutate(async () => { await request(`/api/nodes/${selected.id}/move`, { method: "POST", body: JSON.stringify({ targetParentId, childHandling }) }); setCurrentLocationId(targetParentId); setSelectedIds(new Set([selected.id])); })} />} + {dialog === "delete" && selected && 0 || busy} onClose={() => setDialog(null)} onConfirm={() => mutate(async () => { await request(`/api/nodes/${selected.id}`, { method: "DELETE" }); setSelectedId(null); })} />} + {dialog === "bulkMove" && selectedNodes.length > 0 && setDialog(null)} onMove={(targetParentId, childHandling) => mutate(async () => { await request("/api/nodes/bulk/move", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds], targetParentId, childHandling }) }); setCurrentLocationId(targetParentId); })} />} + {dialog === "bulkDelete" && selectedNodes.length > 0 && node.children.length === 0) ? "Only the selected empty nodes will be deleted. This cannot be undone." : "Every selected node must be empty before deletion."} disabled={busy || selectedNodes.some((node) => node.children.length > 0)} onClose={() => setDialog(null)} onConfirm={() => mutate(async () => { await request("/api/nodes/bulk/delete", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds] }) }); setSelectedIds(new Set()); setSelectedId(null); })} />} + {dialog === "bulkClassify" && selectedNodes.length > 0 && setDialog(null)} onConfirm={(payload) => mutate(() => request("/api/nodes/bulk/classification", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds], ...payload }) }), `Updated ${selectedNodes.length} nodes`)} />} + {dialog === "individualize" && selected && selected.quantity !== null && setDialog(null)} onConfirm={(names) => mutate(async () => { await request(`/api/nodes/${selected.id}/individualize`, { method: "POST", body: JSON.stringify({ names }) }); setSelectedId(selected.id); }, `Created ${names.length} individually tracked nodes`)} />} + {dialog === "combine" && selectedNodes.length > 1 && setDialog(null)} onConfirm={(retainedNodeId, name) => mutate(async () => { await request("/api/nodes/bulk/combine", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds], retainedNodeId, name }) }); setSelectedIds(new Set()); setSelectedId(retainedNodeId); }, `Combined ${selectedNodes.length} nodes into a quantity`)} />} + {dialog === "label" && root && ({ node, breadcrumb: findPath(root, node.id).map((part) => part.name).join(" / ") }))} onClose={() => setDialog(null)} />} + {dialog === "description" && selected && setDialog(null)} onSave={(description) => mutate(() => request(`/api/nodes/${selected.id}`, { method: "PATCH", body: JSON.stringify({ description: description || null }) }), "Description updated")} />} + {dialog === "type" && selectedNodes.length > 0 && setDialog(null)} onSave={(typeId) => mutate(() => request("/api/nodes/bulk/classification", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds], setType: true, typeId, addTagIds: [], removeTagIds: [] }) }), `Updated ${selectedNodes.length} ${selectedNodes.length === 1 ? "node" : "nodes"}`)} />} + {dialog === "tags" && selectedNodes.length > 0 && setDialog(null)} onSave={(addTagIds, removeTagIds) => mutate(() => request("/api/nodes/bulk/classification", { method: "POST", body: JSON.stringify({ nodeIds: [...selectedIds], setType: false, typeId: null, addTagIds, removeTagIds }) }), `Updated ${selectedNodes.length} ${selectedNodes.length === 1 ? "node" : "nodes"}`)} />} + {pendingDrop && byId.get(id)).filter((node): node is ApiNode => Boolean(node))} nodes={nodes} initialTarget={pendingDrop.targetParentId} busy={busy} onClose={() => setPendingDrop(null)} onMove={(targetParentId, childHandling) => mutate(async () => { + if (pendingDrop.nodeIds.length === 1) await request(`/api/nodes/${pendingDrop.nodeIds[0]}/move`, { method: "POST", body: JSON.stringify({ targetParentId, childHandling }) }); + else await request("/api/nodes/bulk/move", { method: "POST", body: JSON.stringify({ nodeIds: pendingDrop.nodeIds, targetParentId, childHandling }) }); + setSelectedIds(new Set(pendingDrop.nodeIds)); setSelectedId(pendingDrop.nodeIds[0]); setCurrentLocationId(targetParentId); setPendingDrop(null); + }, `Moved ${pendingDrop.nodeIds.length} ${pendingDrop.nodeIds.length === 1 ? "node" : "nodes"}`)} />} + {searchOpen && setSearchOpen(false)} onSelect={(id) => { revealNode(id); setSearchOpen(false); }} />} + {contextMenu && selectedNodes.length > 0 && setContextMenu(null)} onAction={(action) => { setContextMenu(null); if (action === "open" && selectedNodes.length === 1) openNode(selectedNodes[0].id); else if (action === "rename") setRenamingId(selectedNodes[0].id); else if (action === "description") setDialog("description"); else if (action === "type") setDialog("type"); else if (action === "tags") setDialog("tags"); else if (action === "label") setDialog("label"); else if (action === "individualize") setDialog("individualize"); else if (action === "combine") setDialog("combine"); else if (action === "move") setDialog(selectedNodes.length > 1 ? "bulkMove" : "move"); else if (action === "delete") setDialog(selectedNodes.length > 1 ? "bulkDelete" : "delete"); else if (action === "clear") clearLocationSelection(); }} />} +
+ ); +} + +function BreadcrumbTarget({ node, active, dragOverId, canDropOn, onNavigate, onDragOver, onDrop }: { node: Pick; active: boolean; dragOverId: string | null; canDropOn: (id: string) => boolean; onNavigate: (id: string) => void; onDragOver: (id: string | null) => void; onDrop: (id: string) => void }) { + return ; +} + +function ContentRow({ node, nodeTypes, tags, selectedId, selectedIds, renameRequested, onSelect, onOpen, onContextMenu, onRename, draggedIds, dragOverId, canDropOn, onDragStart, onDragOver, onDragEnd, onDrop }: { node: ApiNode; nodeTypes: NodeType[]; tags: Tag[]; selectedId: string | null; selectedIds: Set; renameRequested: boolean; onSelect: (id: string, shiftKey: boolean, toggleKey: boolean) => void; onOpen: (id: string) => void; onContextMenu: (id: string, event: React.MouseEvent) => void; onRename: (node: ApiNode, name: string) => void } & TreeDragProps) { + const protectedNode = node.id === "root" || node.id === "unsorted"; + const [renaming, setRenaming] = useState(false); const [name, setName] = useState(node.name); const renameInput = useRef(null); + useEffect(() => { if (renaming) renameInput.current?.select(); }, [renaming]); + useEffect(() => { if (renameRequested) setRenaming(true); }, [renameRequested]); + const finishRename = () => { const next = name.trim(); setRenaming(false); if (next && next !== node.name) onRename(node, next); else setName(node.name); }; + const nodeType = nodeTypes.find((type) => type.id === node.typeId); + const nodeTags = tags.filter((tag) => node.tagIds.includes(tag.id)); + const facts = [nodeType?.name ?? "", node.description ?? "", node.quantity !== null ? `Quantity ${node.quantity}` : ""].filter(Boolean); + return
onDragStart(node.id, event)} onDragEnd={onDragEnd} onContextMenu={(event) => !protectedNode && onContextMenu(node.id, event)} onDragEnter={(event) => { event.stopPropagation(); onDragOver(node.id); }} onDragOver={(event) => { event.stopPropagation(); if (canDropOn(node.id)) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) onDragOver(null); }} onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(node.id); }}> + + {!protectedNode && } +
; +} + +function InventoryWorkspace({ location, selectedId, selectedIds, renamingId, nodeTypes, tags, onCurrentMenu, onContextMenu, onRename, onSelect, onOpen, ...dragProps }: { location: ApiNode | null; selectedId: string | null; selectedIds: Set; renamingId: string | null; nodeTypes: NodeType[]; tags: Tag[]; onCurrentMenu: (id: string, event: React.MouseEvent) => void; onContextMenu: (id: string, event: React.MouseEvent) => void; onRename: (node: ApiNode, name: string) => void; onSelect: (id: string, shiftKey: boolean, toggleKey: boolean) => void; onOpen: (id: string) => void } & TreeDragProps) { + const currentDrop = dragProps.dragOverId === location?.id && dragProps.canDropOn(location.id); + const [headingName, setHeadingName] = useState(location?.name ?? ""); const headingInput = useRef(null); const renameHeading = Boolean(location && renamingId === location.id && location.id !== "root" && location.id !== "unsorted"); + const [filters, setFilters] = useState([]); + const [localQuery, setLocalQuery] = useState(""); + useEffect(() => { setHeadingName(location?.name ?? ""); }, [location?.id, location?.name]); + useEffect(() => { setLocalQuery(""); }, [location?.id]); + useEffect(() => { if (renameHeading) headingInput.current?.select(); }, [renameHeading]); + const finishHeadingRename = () => { const next = headingName.trim(); if (location && next) onRename(location, next); }; + const visibleChildren = useMemo(() => { + const query = localQuery.trim().toLocaleLowerCase(); + return (location?.children ?? []).filter((node) => (!query || node.name.toLocaleLowerCase().includes(query)) && filters.every((filter) => nodeMatchesFilter(node, filter))); + }, [filters, localQuery, location]); + const locationType = nodeTypes.find((type) => type.id === location?.typeId); + const locationTags = tags.filter((tag) => location?.tagIds.includes(tag.id)); + const locationFacts = [locationType?.name ?? "", location?.description ?? ""].filter(Boolean); + return
+
{renameHeading ? setHeadingName(event.target.value)} onBlur={finishHeadingRename} onKeyDown={(event) => { if (event.key === "Enter") finishHeadingRename(); if (event.key === "Escape" && location) { setHeadingName(location.name); onRename(location, location.name); } }} /> :

{location?.id === "root" ? "Inventory" : location?.name ?? "Inventory"}

}{locationFacts.length > 0 &&

{locationFacts.join(" · ")}

}{locationTags.length > 0 && {locationTags.map((tag) => {tag.name})}}
{location && location.id !== "root" && location.id !== "unsorted" && }
+ +
{ if (event.target === event.currentTarget) onSelect("", false, false); }} onDragEnter={() => location && dragProps.onDragOver(location.id)} onDragOver={(event) => { if (location && dragProps.canDropOn(location.id)) event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); if (location) dragProps.onDrop(location.id); }}>{location ? visibleChildren.length ? visibleChildren.map((node) => ) : filters.length || localQuery ?

No nodes match

Change or clear the search and filters to see this node's contents.

:

Nothing here yet

Drag nodes here or create a new one.

: }
+
; +} + +type FilterProperty = "name" | "description" | "type" | "tag" | "quantity"; +interface NodeFilter { id: number; property: FilterProperty; value: string } +const FILTER_LABELS: Record = { name: "Name", description: "Description", type: "Type", tag: "Tag", quantity: "Quantity" }; + +function nodeMatchesFilter(node: ApiNode, filter: NodeFilter) { + const query = filter.value.trim().toLocaleLowerCase(); + if (filter.property === "name") return node.name.toLocaleLowerCase().includes(query); + if (filter.property === "description") return (node.description ?? "").toLocaleLowerCase().includes(query); + if (filter.property === "type") return node.typeId === filter.value; + if (filter.property === "tag") return node.tagIds.includes(filter.value); + if (filter.value === "set") return node.quantity !== null; + if (filter.value === "not-set") return node.quantity === null; + return node.quantity === Number(filter.value); +} + +function NodeFilters({ filters, nodeTypes, tags, localQuery, onLocalQueryChange, onChange }: { filters: NodeFilter[]; nodeTypes: NodeType[]; tags: Tag[]; localQuery: string; onLocalQueryChange: (value: string) => void; onChange: (filters: NodeFilter[]) => void }) { + const [open, setOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const trigger = useRef(null); + const searchInput = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const add = (property: FilterProperty) => { onChange([...filters, { id: Date.now(), property, value: property === "quantity" ? "set" : "" }]); setOpen(false); }; + const show = () => { const rect = trigger.current?.getBoundingClientRect(); if (rect) setPosition({ top: rect.bottom + 4, left: rect.left }); setOpen(true); }; + useEffect(() => { if (!open) return; const close = () => setOpen(false); window.addEventListener("resize", close); window.addEventListener("scroll", close, true); return () => { window.removeEventListener("resize", close); window.removeEventListener("scroll", close, true); }; }, [open]); + const update = (id: number, value: string) => onChange(filters.map((item) => item.id === id ? { ...item, value } : item)); + const valueLabel = (filter: NodeFilter) => filter.property === "type" ? nodeTypes.find((item) => item.id === filter.value)?.name : filter.property === "tag" ? tags.find((item) => item.id === filter.value)?.name : filter.property === "quantity" ? filter.value === "set" ? "is set" : filter.value === "not-set" ? "is not set" : `is ${filter.value}` : filter.value ? `contains ${filter.value}` : "contains…"; + const openSearch = () => { setSearchOpen(true); requestAnimationFrame(() => searchInput.current?.focus()); }; + const closeSearch = () => { onLocalQueryChange(""); setSearchOpen(false); }; + return
+ {filters.map((filter) =>
{FILTER_LABELS[filter.property]}{valueLabel(filter) || "select…"}{filter.property === "type" ? : filter.property === "tag" ? : filter.property === "quantity" ? : update(filter.id, event.target.value)} />}
)} + +
onLocalQueryChange(event.target.value)} onBlur={() => { if (!localQuery) setSearchOpen(false); }} onKeyDown={(event) => { if (event.key === "Escape") closeSearch(); }} />
{searchOpen || localQuery ? : null}
+
{filters.length > 0 &&
} + {open && createPortal(<>)}
, document.body)} + ; +} + +function TagSelector({ tags, selectedIds, disabled, onChange, onCreateTag, onUpdateTag, onDeleteTag }: { tags: Tag[]; selectedIds: Set; disabled?: boolean; onChange: (ids: Set) => void; onCreateTag: (name: string) => Promise; onUpdateTag: (tag: Tag) => Promise; onDeleteTag: (id: string) => Promise }) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + const [editing, setEditing] = useState(null); + const [editName, setEditName] = useState(""); + const [deleteCandidate, setDeleteCandidate] = useState(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const selected = tags.filter((tag) => selectedIds.has(tag.id)); + const options = tags.filter((tag) => tag.name.toLowerCase().includes(query.trim().toLowerCase())); + const canCreate = query.trim() !== "" && !tags.some((tag) => tag.name.toLowerCase() === query.trim().toLowerCase()); + const toggle = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); onChange(next); }; + const create = async () => { + const name = query.trim(); if (!name || creating) return; + setCreating(true); setCreateError(""); + try { const tag = await onCreateTag(name); onChange(new Set([...selectedIds, tag.id])); setQuery(""); } + catch (error) { setCreateError(messageOf(error)); } + finally { setCreating(false); } + }; + const saveName = async () => { if (!editing || !editName.trim() || editName.trim() === editing.name) return; const updated = await onUpdateTag({ ...editing, name: editName.trim() }); setEditing(updated); }; + const setTagColor = async (color: string) => { if (!editing || color === editing.color) return; const updated = await onUpdateTag({ ...editing, color }); setEditing(updated); }; + const removeTag = async () => { if (!deleteCandidate) return; await onDeleteTag(deleteCandidate.id); const next = new Set(selectedIds); next.delete(deleteCandidate.id); onChange(next); setDeleteCandidate(null); setEditing(null); }; + const placeMenu = useCallback(() => { + const rect = triggerRef.current?.getBoundingClientRect(); if (!rect) return; + const width = 240; const estimatedHeight = 270; const margin = 8; + const top = rect.bottom + 6 + estimatedHeight <= window.innerHeight ? rect.bottom + 6 : Math.max(margin, rect.top - estimatedHeight - 6); + setPosition({ top, left: Math.max(margin, Math.min(rect.right - width, window.innerWidth - width - margin)) }); + }, []); + useEffect(() => { + if (!open) return; + placeMenu(); + const close = (event: MouseEvent | KeyboardEvent) => { + if (event instanceof KeyboardEvent) { if (event.key === "Escape") setOpen(false); return; } + const target = event.target as globalThis.Node; + if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) setOpen(false); + }; + window.addEventListener("resize", placeMenu); window.addEventListener("scroll", placeMenu, true); document.addEventListener("mousedown", close); document.addEventListener("keydown", close); + return () => { window.removeEventListener("resize", placeMenu); window.removeEventListener("scroll", placeMenu, true); document.removeEventListener("mousedown", close); document.removeEventListener("keydown", close); }; + }, [open, placeMenu]); + return
+
{selected.map((tag) => {tag.name}{!disabled && })} + {!selected.length && No tags} +
+ {!disabled && } + {open && createPortal(
{editing ?
Edit tag
setEditName(event.target.value)} onBlur={() => void saveName()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void saveName(); } }} aria-label="Tag name" autoFocus />
{TAG_COLORS.map(([name, color]) => )}
: <>
{ setQuery(event.target.value); setCreateError(""); }} onKeyDown={(event) => { if (event.key === "Enter" && canCreate) { event.preventDefault(); void create(); } }} placeholder="Find or create a tag" aria-label="Find or create a tag" autoFocus />
{canCreate && }{options.map((tag) =>
)}{!options.length && !canCreate &&
Type a name to create a tag
}{createError &&
{createError}
}
}
, document.body)} + {deleteCandidate && createPortal( setDeleteCandidate(null)} onConfirm={() => void removeTag()} />, document.body)} +
; +} + +function SearchDialog({ nodes, root, nodeTypes, tags, onClose, onSelect }: { nodes: FlatNode[]; root: ApiNode | null; nodeTypes: NodeType[]; tags: Tag[]; onClose: () => void; onSelect: (id: string) => void }) { + const [query, setQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [tagFilter, setTagFilter] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const input = useRef(null); + const results = nodes.filter(({ node }) => (!query.trim() || node.name.toLowerCase().includes(query.trim().toLowerCase())) && (!typeFilter || node.typeId === typeFilter) && (!tagFilter || node.tagIds.includes(tagFilter))).slice(0, query.trim() || typeFilter || tagFilter ? undefined : 12); + useEffect(() => input.current?.focus(), []); + useEffect(() => setActiveIndex(0), [query]); + useEffect(() => { const close = (event: KeyboardEvent) => event.key === "Escape" && onClose(); window.addEventListener("keydown", close); return () => window.removeEventListener("keydown", close); }, [onClose]); + const onSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((index) => Math.min(index + 1, results.length - 1)); } + if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); } + if (event.key === "Enter" && results[activeIndex]) { event.preventDefault(); onSelect(results[activeIndex].node.id); } + }; + return
event.target === event.currentTarget && onClose()}>
setQuery(event.target.value)} onKeyDown={onSearchKeyDown} placeholder="Search inventory" aria-label="Search inventory" />{query && }
{(nodeTypes.length > 0 || tags.length > 0) &&
}
{query || typeFilter || tagFilter ? `${results.length} ${results.length === 1 ? "result" : "results"}` : "All nodes"}
{results.map(({ node }, index) => )}{!results.length &&

No matching nodes

Try another name or filter.

}
NavigateEsc Close
; +} + +function MetadataWorkspace({ nodeTypes, busy, mutate }: { nodeTypes: NodeType[]; busy: boolean; mutate: (action: () => Promise, success?: string) => Promise }) { + const [typeName, setTypeName] = useState(""); + return

Types

Optional metadata for filtering and identifying nodes. Types never restrict the hierarchy.

+

Types

One optional type per node

{ event.preventDefault(); const name = typeName.trim(); if (!name) return; void mutate(async () => { await request("/api/node-types", { method: "POST", body: JSON.stringify({ name }) }); setTypeName(""); }, `Created type “${name}”`); }}> setTypeName(event.target.value)} placeholder="New type name" />
{nodeTypes.map((type) => )}{!nodeTypes.length &&

No types yet.

}
+
; +} + +const TYPE_COLORS = [ + ["Grey", "#9b9b9b"], ["Yellow", "#dcc000"], ["Orange", "#f08a00"], ["Red", "#f04520"], ["Pink", "#f030a0"], + ["Purple", "#a840f0"], ["Blue", "#4a60f0"], ["Ice", "#2aa7ee"], ["Teal", "#00bba7"], ["Lime", "#5ec400"], +] as const; + +const TAG_COLORS = [ + ["Grey", "#8c9ea5"], ["Yellow", "#b2a616"], ["Orange", "#d3720d"], ["Red", "#e2400c"], ["Pink", "#ca1b8e"], + ["Purple", "#9e30c4"], ["Blue", "#4f6af0"], ["Ice", "#1c8bca"], ["Teal", "#0caaa3"], ["Lime", "#64b90f"], +] as const; + +function PresetColorPicker({ value, colors, label, onChange }: { value: string; colors: ReadonlyArray; label: string; onChange: (color: string) => void }) { + const selected = colors.find(([, color]) => color === value.toLowerCase()); + return
{colors.map(([name, color]) => )}
; +} + +function TypeEditor({ value, busy, mutate }: { value: NodeType; busy: boolean; mutate: (action: () => Promise, success?: string) => Promise }) { + const [name, setName] = useState(value.name); const [description, setDescription] = useState(value.description ?? ""); const [color, setColor] = useState(value.color ?? TYPE_COLORS[0][1]); + const dirty = name.trim() !== value.name || description.trim() !== (value.description ?? "") || color !== (value.color ?? TYPE_COLORS[0][1]); + return
setName(event.target.value)} /> setDescription(event.target.value)} placeholder="Description (optional)" />
; +} + +function BulkClassificationDialog({ count, nodeTypes, tags, busy, onClose, onConfirm }: { count: number; nodeTypes: NodeType[]; tags: Tag[]; busy: boolean; onClose: () => void; onConfirm: (payload: { setType: boolean; typeId: string | null; addTagIds: string[]; removeTagIds: string[] }) => void }) { + const [setType, setSetType] = useState(false); const [typeId, setTypeId] = useState(""); const [tagActions, setTagActions] = useState>({}); + const addTagIds = tags.filter((tag) => tagActions[tag.id] === "add").map((tag) => tag.id); const removeTagIds = tags.filter((tag) => tagActions[tag.id] === "remove").map((tag) => tag.id); + return }>{setType && }
Tags
{tags.map((tag) => )}{!tags.length && No tags have been created.}
; +} + +interface PortableNode { name: string; description?: string; quantity?: number; type?: string; tags?: string[]; lookupCode: string; children?: PortableNode[] } + +function ExportWorkspace({ root, nodeTypes, tags }: { root: ApiNode | null; nodeTypes: NodeType[]; tags: Tag[] }) { + const [generatedAt] = useState(() => new Date().toISOString()); + const roots = root?.children ?? []; + const included = new Set(); + const collect = (node: ApiNode) => { included.add(node.id); node.children.forEach(collect); }; roots.forEach(collect); + const usedTypeIds = new Set(); const usedTagIds = new Set(); + const encode = (node: ApiNode): PortableNode => { + if (node.typeId) usedTypeIds.add(node.typeId); node.tagIds.forEach((id) => usedTagIds.add(id)); + const children = node.children.map(encode); + return { name: node.name, ...(node.description ? { description: node.description } : {}), ...(node.quantity !== null ? { quantity: node.quantity } : {}), ...(node.typeId ? { type: nodeTypes.find((type) => type.id === node.typeId)?.name } : {}), ...(node.tagIds.length ? { tags: node.tagIds.map((id) => tags.find((tag) => tag.id === id)?.name).filter((name): name is string => Boolean(name)) } : {}), lookupCode: node.lookupCode, ...(children.length ? { children } : {}) }; + }; + const encodedNodes = roots.map(encode); + const payload = { format: "box-manifest-portable", exportedAt: generatedAt, note: "Internal UUIDs are intentionally omitted. Lookup codes are included as human-readable references and may change when re-imported.", types: nodeTypes.filter((type) => usedTypeIds.has(type.id)).map(({ name, description, iconKey, color }) => ({ name, ...(description ? { description } : {}), ...(iconKey ? { iconKey } : {}), ...(color ? { color } : {}) })), tags: tags.filter((tag) => usedTagIds.has(tag.id)).map(({ name, color }) => ({ name, ...(color ? { color } : {}) })), nodes: encodedNodes }; + const json = JSON.stringify(payload, null, 2); + const download = () => { const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `box-manifest-inventory-${generatedAt.slice(0, 10)}.json`; link.click(); URL.revokeObjectURL(url); }; + return

Export hierarchy

Portable, human-readable JSON containing the entire inventory.

{included.size} nodes{payload.types.length} types · {payload.tags.length} tags

Names, descriptions, quantities, hierarchy, types, tags, and lookup-code references are included. Internal UUIDs are omitted.

Preview

Entire inventory · no internal UUIDs

{json}
; +} + +function ImportWorkspace({ nodes, selectedId, busy, mutate }: { nodes: FlatNode[]; selectedId: string | null; busy: boolean; mutate: (action: () => Promise, success?: string) => Promise }) { + const [parentId, setParentId] = useState(selectedId ?? "unsorted"); + const [manifest, setManifest] = useState(""); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(""); + const previewImport = async (event: FormEvent) => { + event.preventDefault(); setError(""); + try { + const parsed = JSON.parse(manifest) as { nodes: ImportNode[] }; + setPreview(await request("/api/imports/tree/preview", { method: "POST", body: JSON.stringify({ parentId, manifest: parsed }) })); + } catch (cause) { setPreview(null); setError(cause instanceof SyntaxError ? `Invalid JSON: ${cause.message}` : messageOf(cause)); } + }; + const clearPreview = () => setPreview(null); + return

Import hierarchy

Preview a structured JSON manifest before changing the inventory.

+
+ +