add metadata, inventory workflows, web client

This commit is contained in:
2026-08-27 23:28:29 -05:00
parent 84a88d1264
commit da63ee8f7c
32 changed files with 5397 additions and 518 deletions
+2
View File
@@ -17,3 +17,5 @@
local.properties
/server/data/
/server/box-manifest-server
/web/node_modules/
/web/dist/
+19 -6
View File
@@ -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:
+323
View File
@@ -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
+3
View File
@@ -23,6 +23,9 @@
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"quantity": {
"type": ["integer", "null"],
"minimum": 0
@@ -13,10 +13,14 @@ data class ApiNode(
val quantity: Long?,
val children: List<ApiNode> = emptyList(),
val lookupCode: String = "",
val description: String? = null,
val typeId: String? = null,
val tagIds: List<String> = emptyList(),
)
data class ImportNode(
val name: String,
val description: String?,
val quantity: Long?,
val children: List<ImportNode>,
)
@@ -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<String>): List<ApiNode> {
suspend fun createNodes(parentId: String, names: List<String>, description: String?): List<ApiNode> {
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() }
@@ -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<String>) {
api.createNodes(parentId, names)
suspend fun createChildren(parentId: String, names: List<String>, 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) {
@@ -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<String>) -> Unit,
onAddOne: (String, String?, Long?) -> Unit,
onAddMultiple: (List<String>, 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) }
@@ -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<String>) = perform {
repository.createChildren(_state.value.currentNodeId, names)
fun createChildren(names: List<String>, 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()
}
+11 -7
View File
@@ -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
+75
View File
@@ -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.
+352
View File
@@ -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 | 400500 |
| Small | 12px | 18px | 0 | 400500 |
| Common | 14px | 22px | -0.12px | 400600 |
| Paragraph | 16px | 24px | -0.2px | 400600 |
| 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; 72480px allowed |
| Secondary/right panel | 336px default; 240480px 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.
+1 -1
View File
@@ -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 {
+211 -10
View File
@@ -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,
})
+67
View File
@@ -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(
+8 -1
View File
@@ -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
}
+319
View File
@@ -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)
}
+94
View File
@@ -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)
}
}
}
+273 -25
View File
@@ -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
}
+85 -7
View File
@@ -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)
}
-338
View File
@@ -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();
+6 -95
View File
@@ -1,103 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f7f5" />
<meta name="color-scheme" content="light dark" />
<title>Box Manifest</title>
<script src="/app.js" defer></script>
</head>
<body>
<h1>Box Manifest</h1>
<p id="status" role="status"></p>
<section>
<h2>Tree</h2>
<button id="refresh" type="button">Refresh</button>
<div id="tree">Loading…</div>
</section>
<section>
<h2>Selected node</h2>
<p id="selected">None</p>
<form id="update-form">
<h3>Update</h3>
<label>
Name
<input id="update-name" required />
</label>
<label>
Quantity
<input id="update-quantity" type="number" min="0" step="1" />
</label>
<button type="submit">Update selected node</button>
</form>
<form id="move-form">
<h3>Move</h3>
<label>
New parent
<select id="move-parent" required></select>
</label>
<label>
Children
<select id="move-children">
<option value="WITH_SUBTREE">Move with subtree</option>
<option value="PROMOTE_CHILDREN">Move direct children up</option>
<option value="CHILDREN_TO_UNSORTED">
Move direct children to Unsorted
</option>
</select>
</label>
<button type="submit">Move selected node</button>
</form>
<button id="delete" type="button">Delete selected empty node</button>
</section>
<section>
<form id="create-form">
<h2>Create child</h2>
<p id="create-parent">Parent: Unsorted</p>
<label>
Name
<input id="create-name" required />
</label>
<label>
Quantity
<input id="create-quantity" type="number" min="0" step="1" />
</label>
<button type="submit">Create</button>
</form>
</section>
<section>
<form id="import-form">
<h2>Import tree</h2>
<label>
Parent
<select id="import-parent" required></select>
</label>
<label>
JSON manifest
<textarea
id="import-manifest"
rows="16"
cols="72"
required
></textarea>
</label>
<button type="submit">Preview import</button>
</form>
<div id="import-preview" hidden>
<h3>Import preview</h3>
<p id="import-summary"></p>
<ul id="import-warnings"></ul>
<div id="import-normalized"></div>
<button id="import-commit" type="button">Commit import</button>
<button id="import-cancel" type="button">Cancel preview</button>
</div>
</section>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1878
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+719
View File
@@ -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<string>;
dragOverId: string | null;
canDropOn: (id: string) => boolean;
onDragStart: (id: string, event: React.DragEvent<HTMLElement>) => 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<string> {
const ids = new Set<string>([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<ApiNode | null>(null);
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [currentLocationId, setCurrentLocationId] = useState("root");
const [locationBackStack, setLocationBackStack] = useState<string[]>([]);
const [locationForwardStack, setLocationForwardStack] = useState<string[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState<View>("inventory");
const [searchOpen, setSearchOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<string | null>(null);
const [draggedIds, setDraggedIds] = useState<Set<string>>(new Set());
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(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<string | null>(null);
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
const breadcrumbsRef = useRef<HTMLDivElement>(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<ApiNode>("/api/tree"), request<NodeType[]>("/api/node-types"), request<Tag[]>("/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<unknown>, 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<Tag>("/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<Tag>(`/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<HTMLButtonElement>) => {
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<HTMLElement>) => {
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 (
<div className="app-shell">
<aside className="sidebar">
<div className="brand"><span className="brand-mark"><BoxIcon /></span></div>
<nav className="nav-list" aria-label="Workspace">
<button title="Inventory" className={view === "inventory" ? "nav-item active" : "nav-item"} onClick={() => openNode("root")}><InventoryIcon /><span>Inventory</span></button>
<button title="Import" className={view === "import" ? "nav-item active" : "nav-item"} onClick={() => setView("import")}><ImportIcon /><span>Import</span></button>
<button title="Export" className={view === "export" ? "nav-item active" : "nav-item"} onClick={() => setView("export")}><ExportIcon /><span>Export</span></button>
<button title="Types" className={view === "metadata" ? "nav-item active" : "nav-item"} onClick={() => setView("metadata")}><TypeIcon /><span>Types</span></button>
</nav>
</aside>
<main className="workspace">
<header className="topbar">
<div className="history-actions"><button className="history-button back" disabled={locationBackStack.length === 0} onClick={goBackLocation} aria-label="Back" title="Back (Alt+Left)"><ChevronIcon /></button><button className="history-button" disabled={locationForwardStack.length === 0} onClick={goForwardLocation} aria-label="Forward" title="Forward (Alt+Right)"><ChevronIcon /></button></div>
<div ref={breadcrumbsRef} className="breadcrumbs">
{view !== "inventory" ? <><span>Inventory</span><ChevronIcon /><strong>{view === "import" ? "Import" : view === "export" ? "Export" : "Types"}</strong></> : <><BreadcrumbTarget node={{ id: "root", name: "Inventory" }} active={currentLocationId === "root"} dragOverId={dragOverId} canDropOn={canDropOn} onNavigate={openNode} onDragOver={setDragOverId} onDrop={dropOn} />{path.slice(1).map((node) => <span className="breadcrumb-part" key={node.id}><ChevronIcon /><BreadcrumbTarget node={node} active={currentLocationId === node.id} dragOverId={dragOverId} canDropOn={canDropOn} onNavigate={openNode} onDragOver={setDragOverId} onDrop={dropOn} /></span>)}</>}
</div>
<div className="top-actions">
<button className="search-trigger" onClick={() => setSearchOpen(true)}><SearchIcon /><span>Search</span><kbd> K</kbd></button>
{view === "inventory" && <button className="button primary new-button" onClick={() => setDialog("create")}><EditSquareIcon />New</button>}
</div>
</header>
{status && <div className={status.toLowerCase().includes("error") ? "status error" : "status"} role="status">{status}<button onClick={() => setStatus("")} aria-label="Dismiss"><CloseIcon /></button></div>}
{view === "import" ? (
<ImportWorkspace nodes={nodes} selectedId={currentLocationId} busy={busy} mutate={mutate} />
) : view === "export" ? (
<ExportWorkspace root={root} nodeTypes={nodeTypes} tags={tags} />
) : view === "metadata" ? (
<MetadataWorkspace nodeTypes={nodeTypes} busy={busy} mutate={mutate} />
) : (
<InventoryWorkspace location={currentLocation} selectedId={selectedId} selectedIds={selectedIds} renamingId={renamingId} nodeTypes={nodeTypes} tags={tags} onCurrentMenu={openCurrentNodeMenu} onContextMenu={openContextMenu} onRename={(node, name) => { 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} />
)}
</main>
{dialog === "create" && <CreateDialog parent={currentLocation ?? byId.get("unsorted") ?? root} busy={busy} onClose={() => 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<ApiNode>("/api/nodes", { method: "POST", body: JSON.stringify({ parentId, name: entries[0], description, quantity }) });
setSelectedId(created.id);
}
})} />}
{dialog === "move" && selected && <MoveDialog node={selected} nodes={nodes} busy={busy} onClose={() => 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 && <ConfirmDialog title={`Delete “${selected.name}”?`} message={selected.children.length ? "This node is not empty and cannot be deleted." : "This cannot be undone."} disabled={selected.children.length > 0 || busy} onClose={() => setDialog(null)} onConfirm={() => mutate(async () => { await request(`/api/nodes/${selected.id}`, { method: "DELETE" }); setSelectedId(null); })} />}
{dialog === "bulkMove" && selectedNodes.length > 0 && <BulkMoveDialog nodesToMove={selectedNodes} nodes={nodes} busy={busy} onClose={() => 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 && <ConfirmDialog title={`Delete ${selectedNodes.length} nodes?`} message={selectedNodes.every((node) => 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 && <BulkClassificationDialog count={selectedNodes.length} nodeTypes={nodeTypes} tags={tags} busy={busy} onClose={() => 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 && <IndividualizeDialog node={selected} busy={busy} onClose={() => 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 && <CombineDialog nodes={selectedNodes} busy={busy} onClose={() => 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 && <LabelSheetDialog entries={(selectedNodes.length ? selectedNodes : selected ? [selected] : []).map((node): LabelEntry => ({ node, breadcrumb: findPath(root, node.id).map((part) => part.name).join(" / ") }))} onClose={() => setDialog(null)} />}
{dialog === "description" && selected && <TextEditDialog title="Change description" label="Description" value={selected.description ?? ""} multiline busy={busy} onClose={() => 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 && <TypeAssignmentDialog nodes={selectedNodes} nodeTypes={nodeTypes} busy={busy} onClose={() => 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 && <TagAssignmentDialog nodes={selectedNodes} tags={tags} busy={busy} onCreateTag={createTag} onUpdateTag={updateTag} onDeleteTag={deleteTag} onClose={() => 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 && <DropMoveDialog nodesToMove={pendingDrop.nodeIds.map((id) => 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 && <SearchDialog nodes={nodes} root={root} nodeTypes={nodeTypes} tags={tags} onClose={() => setSearchOpen(false)} onSelect={(id) => { revealNode(id); setSearchOpen(false); }} />}
{contextMenu && selectedNodes.length > 0 && <NodeContextMenu position={contextMenu} nodes={selectedNodes} onClose={() => 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(); }} />}
</div>
);
}
function BreadcrumbTarget({ node, active, dragOverId, canDropOn, onNavigate, onDragOver, onDrop }: { node: Pick<ApiNode, "id" | "name">; active: boolean; dragOverId: string | null; canDropOn: (id: string) => boolean; onNavigate: (id: string) => void; onDragOver: (id: string | null) => void; onDrop: (id: string) => void }) {
return <button className={`${active ? "active" : ""} ${dragOverId === node.id && canDropOn(node.id) ? "breadcrumb-drop-target" : ""}`} onClick={() => onNavigate(node.id)} onDragEnter={(event) => { event.stopPropagation(); onDragOver(node.id); }} onDragOver={(event) => { 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); }}>{node.name}</button>;
}
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<string>; 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<HTMLInputElement>(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 <div className={`tree-row content-row ${selectedId === node.id ? "selected" : ""} ${selectedIds.has(node.id) ? "checked" : ""} ${draggedIds.has(node.id) ? "dragging" : ""} ${dragOverId === node.id ? canDropOn(node.id) ? "drop-target" : "drop-invalid" : ""}`} draggable={!protectedNode && !renaming} onDragStart={(event) => 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); }}>
<button className="tree-node" onKeyDown={(event) => { if (event.key === "Enter") onOpen(node.id); }} onClick={(event) => { if (renaming) return; if (!protectedNode && (event.shiftKey || event.ctrlKey || event.metaKey)) onSelect(node.id, event.shiftKey, event.ctrlKey || event.metaKey); else onOpen(node.id); }}>
<span className="node-tile" style={nodeType?.color ? { background: nodeType.color } : undefined}>{node.name.slice(0, 1).toUpperCase()}</span>
<span className="tree-copy">{renaming ? <input ref={renameInput} className="inline-rename" value={name} onClick={(event) => event.stopPropagation()} onChange={(event) => setName(event.target.value)} onBlur={finishRename} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") finishRename(); if (event.key === "Escape") { setName(node.name); setRenaming(false); } }} /> : <strong>{node.name}</strong>}{facts.length > 0 && <small>{facts.join(" · ")}</small>}{nodeTags.length > 0 && <span className="row-tags">{nodeTags.map((tag) => <span key={tag.id}><i style={{ background: tag.color ?? "#888" }} />{tag.name}</span>)}</span>}</span>
</button>
{!protectedNode && <button className="row-rename" aria-label={`Rename ${node.name}`} title="Rename" onClick={(event) => { event.stopPropagation(); setRenaming(true); }}><EditSquareIcon /></button>}
</div>;
}
function InventoryWorkspace({ location, selectedId, selectedIds, renamingId, nodeTypes, tags, onCurrentMenu, onContextMenu, onRename, onSelect, onOpen, ...dragProps }: { location: ApiNode | null; selectedId: string | null; selectedIds: Set<string>; renamingId: string | null; nodeTypes: NodeType[]; tags: Tag[]; onCurrentMenu: (id: string, event: React.MouseEvent<HTMLButtonElement>) => 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<HTMLInputElement>(null); const renameHeading = Boolean(location && renamingId === location.id && location.id !== "root" && location.id !== "unsorted");
const [filters, setFilters] = useState<NodeFilter[]>([]);
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 <section className="inventory-pane">
<div className="pane-heading"><div className="heading-copy">{renameHeading ? <input ref={headingInput} className="heading-rename" value={headingName} onChange={(event) => 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); } }} /> : <h1>{location?.id === "root" ? "Inventory" : location?.name ?? "Inventory"}</h1>}{locationFacts.length > 0 && <p className="heading-facts">{locationFacts.join(" · ")}</p>}{locationTags.length > 0 && <span className="row-tags heading-tags">{locationTags.map((tag) => <span key={tag.id}><i style={{ background: tag.color ?? "#888" }} />{tag.name}</span>)}</span>}</div>{location && location.id !== "root" && location.id !== "unsorted" && <button className="current-node-menu" aria-label={`Menu for ${location.name}`} onClick={(event) => onCurrentMenu(location.id, event)}><MoreIcon /></button>}</div>
<NodeFilters filters={filters} nodeTypes={nodeTypes} tags={tags} localQuery={localQuery} onLocalQueryChange={setLocalQuery} onChange={setFilters} />
<div className={`main-tree folder-contents ${selectedIds.size ? "selection-mode" : ""} ${currentDrop ? "current-drop-target" : ""}`} onClick={(event) => { 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) => <ContentRow key={node.id} node={node} nodeTypes={nodeTypes} tags={tags} selectedId={selectedId} selectedIds={selectedIds} renameRequested={renamingId === node.id} onContextMenu={onContextMenu} onRename={onRename} onSelect={onSelect} onOpen={onOpen} {...dragProps} />) : filters.length || localQuery ? <div className="empty-state folder-empty"><FilterIcon /><h2>No nodes match</h2><p>Change or clear the search and filters to see this node's contents.</p></div> : <div className="empty-state folder-empty"><BoxIcon /><h2>Nothing here yet</h2><p>Drag nodes here or create a new one.</p></div> : <SkeletonRows />}</div>
</section>;
}
type FilterProperty = "name" | "description" | "type" | "tag" | "quantity";
interface NodeFilter { id: number; property: FilterProperty; value: string }
const FILTER_LABELS: Record<FilterProperty, string> = { 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<HTMLButtonElement>(null);
const searchInput = useRef<HTMLInputElement>(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 <div className="node-filters"><div className="node-filter-list">
{filters.map((filter) => <div className="node-filter active" key={filter.id}><strong>{FILTER_LABELS[filter.property]}</strong><span>{valueLabel(filter) || "select…"}</span>{filter.property === "type" ? <select aria-label="Type filter" value={filter.value} onChange={(event) => update(filter.id, event.target.value)}><option value="">Select type</option>{nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select> : filter.property === "tag" ? <select aria-label="Tag filter" value={filter.value} onChange={(event) => update(filter.id, event.target.value)}><option value="">Select tag</option>{tags.map((tag) => <option key={tag.id} value={tag.id}>{tag.name}</option>)}</select> : filter.property === "quantity" ? <select aria-label="Quantity filter" value={filter.value} onChange={(event) => update(filter.id, event.target.value)}><option value="set">is set</option><option value="not-set">is not set</option><option value="0">is 0</option><option value="1">is 1</option></select> : <input autoFocus aria-label={`${FILTER_LABELS[filter.property]} filter value`} value={filter.value} placeholder="Value" onChange={(event) => update(filter.id, event.target.value)} />}<button aria-label="Remove filter" onClick={() => onChange(filters.filter((item) => item.id !== filter.id))}><CloseIcon /></button></div>)}
<button ref={trigger} className="add-filter" onClick={() => open ? setOpen(false) : show()}><FilterPlusIcon />Filter</button>
<div className={`local-node-search ${searchOpen || localQuery ? "active" : ""}`} onClick={openSearch}><button aria-label="Search current node" title="Search current node" onClick={openSearch}><SearchIcon /></button><div><input ref={searchInput} value={localQuery} aria-label="Search immediate children" placeholder="Search" onChange={(event) => onLocalQueryChange(event.target.value)} onBlur={() => { if (!localQuery) setSearchOpen(false); }} onKeyDown={(event) => { if (event.key === "Escape") closeSearch(); }} /></div>{searchOpen || localQuery ? <button aria-label="Clear current node search" onMouseDown={(event) => event.preventDefault()} onClick={(event) => { event.stopPropagation(); closeSearch(); }}><CloseIcon /></button> : null}</div>
</div>{filters.length > 0 && <div className="node-filter-actions"><button className="clear-filters" onClick={() => onChange([])}>Clear</button></div>}
{open && createPortal(<><button className="filter-menu-scrim" aria-label="Close filters" onClick={() => setOpen(false)} /><div className="filter-property-menu" style={position} role="menu"><div className="filter-menu-title"><FilterIcon />Filter by</div>{(["name", "description", "type", "tag", "quantity"] as FilterProperty[]).map((property) => <button key={property} role="menuitem" onClick={() => add(property)}><span className="filter-property-icon">{FILTER_LABELS[property].slice(0, 1)}</span>{FILTER_LABELS[property]}</button>)}</div></>, document.body)}
</div>;
}
function TagSelector({ tags, selectedIds, disabled, onChange, onCreateTag, onUpdateTag, onDeleteTag }: { tags: Tag[]; selectedIds: Set<string>; disabled?: boolean; onChange: (ids: Set<string>) => void; onCreateTag: (name: string) => Promise<Tag>; onUpdateTag: (tag: Tag) => Promise<Tag>; onDeleteTag: (id: string) => Promise<void> }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [editing, setEditing] = useState<Tag | null>(null);
const [editName, setEditName] = useState("");
const [deleteCandidate, setDeleteCandidate] = useState<Tag | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 <div className={`tag-selector ${disabled ? "disabled" : ""}`}>
<div className="selected-tag-list">{selected.map((tag) => <span key={tag.id} className="selected-tag" style={{ color: tag.color ?? "#595959", background: `color-mix(in srgb, ${tag.color ?? "#888"} 18%, transparent)` }}><span>{tag.name}</span>{!disabled && <button type="button" aria-label={`Remove ${tag.name}`} onClick={() => toggle(tag.id)}><CloseIcon /></button>}</span>)}
{!selected.length && <span className="tag-placeholder">No tags</span>}
</div>
{!disabled && <button ref={triggerRef} type="button" className={`tag-option-trigger ${open ? "active" : ""}`} aria-haspopup="listbox" aria-expanded={open} onClick={() => { if (!open) placeMenu(); else setQuery(""); setOpen(!open); }}><PlusIcon /> Add tag</button>}
{open && createPortal(<div ref={menuRef} className="tag-option-popover tag-option-portal" style={{ top: position.top, left: position.left }}>{editing ? <div className="tag-option-editor"><div className="tag-edit-heading"><button type="button" onClick={() => setEditing(null)}><ChevronIcon /></button><strong>Edit tag</strong></div><div className="tag-option-search"><input value={editName} onChange={(event) => setEditName(event.target.value)} onBlur={() => void saveName()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void saveName(); } }} aria-label="Tag name" autoFocus /></div><div className="tag-color-grid">{TAG_COLORS.map(([name, color]) => <button type="button" key={name} className={editing.color?.toLowerCase() === color ? "selected" : ""} title={name} aria-label={name} onClick={() => void setTagColor(color)}><i style={{ background: color }} /></button>)}</div><button type="button" className="tag-delete-action" onClick={() => setDeleteCandidate(editing)}>Delete tag</button></div> : <><div className="tag-option-search"><SearchIcon /><input value={query} onChange={(event) => { 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 /></div><div className="tag-option-list" role="listbox" aria-multiselectable="true">{canCreate && <button type="button" className="create-tag-option" disabled={creating} onClick={() => void create()}><PlusIcon /><span>{creating ? "Creating…" : `Create “${query.trim()}”`}</span><b /></button>}{options.map((tag) => <div className={`tag-option-row ${selectedIds.has(tag.id) ? "selected" : ""}`} key={tag.id}><button type="button" className="tag-option-value" role="option" aria-selected={selectedIds.has(tag.id)} onClick={() => toggle(tag.id)}><i style={{ background: tag.color ?? "#888" }} /><span>{tag.name}</span><b aria-hidden="true">{selectedIds.has(tag.id) && <CheckIcon />}</b></button><button type="button" className="tag-option-more" aria-label={`Edit ${tag.name}`} onClick={() => { setEditing(tag); setEditName(tag.name); }}><MoreIcon /></button></div>)}{!options.length && !canCreate && <div className="tag-option-empty">Type a name to create a tag</div>}{createError && <div className="tag-create-error">{createError}</div>}</div></>}</div>, document.body)}
{deleteCandidate && createPortal(<ConfirmDialog title="Delete tag?" message={`“${deleteCandidate.name}” will be removed from every node. This cannot be undone.`} disabled={false} onClose={() => setDeleteCandidate(null)} onConfirm={() => void removeTag()} />, document.body)}
</div>;
}
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 <div className="search-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><section className="search-dialog" role="dialog" aria-modal="true" aria-label="Search inventory"><div className="search-input-row"><SearchIcon /><input ref={input} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={onSearchKeyDown} placeholder="Search inventory" aria-label="Search inventory" />{query && <button className="clear-search" onClick={() => setQuery("")} aria-label="Clear search"><CloseIcon /></button>}</div>{(nodeTypes.length > 0 || tags.length > 0) && <div className="search-filters"><select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}><option value="">All types</option>{nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select><select value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}><option value="">All tags</option>{tags.map((tag) => <option key={tag.id} value={tag.id}>{tag.name}</option>)}</select></div>}<div className="search-section-label">{query || typeFilter || tagFilter ? `${results.length} ${results.length === 1 ? "result" : "results"}` : "All nodes"}</div><div className="search-result-list">{results.map(({ node }, index) => <button key={node.id} className={`search-result-row ${index === activeIndex ? "active" : ""}`} onMouseEnter={() => setActiveIndex(index)} onClick={() => onSelect(node.id)}><span className="search-result-icon"><BoxIcon /></span><span className="search-result-copy"><strong>{node.name}</strong><small>{[nodeTypes.find((type) => type.id === node.typeId)?.name ?? "", root ? findPath(root, node.id).map((part) => part.name).join(" / ") : ""].filter(Boolean).join(" · ")}</small></span></button>)}{!results.length && <div className="empty-state search-empty"><SearchIcon /><h2>No matching nodes</h2><p>Try another name or filter.</p></div>}</div><footer className="search-footer"><span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span><span><kbd>Esc</kbd> Close</span></footer></section></div>;
}
function MetadataWorkspace({ nodeTypes, busy, mutate }: { nodeTypes: NodeType[]; busy: boolean; mutate: (action: () => Promise<unknown>, success?: string) => Promise<void> }) {
const [typeName, setTypeName] = useState("");
return <section className="metadata-workspace"><div className="pane-heading"><div><h1>Types</h1><p>Optional metadata for filtering and identifying nodes. Types never restrict the hierarchy.</p></div></div><div className="metadata-grid single">
<div className="card metadata-card"><div className="card-heading"><div><h2>Types</h2><p>One optional type per node</p></div></div><form className="metadata-create" onSubmit={(event) => { 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}”`); }}><input value={typeName} onChange={(event) => setTypeName(event.target.value)} placeholder="New type name" /><button className="button primary" disabled={busy || !typeName.trim()}>Add</button></form><div className="metadata-list">{nodeTypes.map((type) => <TypeEditor key={type.id} value={type} busy={busy} mutate={mutate} />)}{!nodeTypes.length && <p className="metadata-empty">No types yet.</p>}</div></div>
</div></section>;
}
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<readonly [string, string]>; label: string; onChange: (color: string) => void }) {
const selected = colors.find(([, color]) => color === value.toLowerCase());
return <details className="preset-color-picker"><summary aria-label={`${label}: ${selected?.[0] ?? "custom"}`} title={`${label}: ${selected?.[0] ?? "custom"}`}><span style={{ background: value }} /></summary><div className="preset-color-popover" role="group" aria-label={label}>{colors.map(([name, color]) => <button key={name} type="button" className={color === value.toLowerCase() ? "selected" : ""} aria-label={name} title={name} onClick={(event) => { onChange(color); event.currentTarget.closest("details")?.removeAttribute("open"); }}><span style={{ background: color }} /></button>)}</div></details>;
}
function TypeEditor({ value, busy, mutate }: { value: NodeType; busy: boolean; mutate: (action: () => Promise<unknown>, success?: string) => Promise<void> }) {
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 <div className="metadata-editor"><PresetColorPicker value={color} colors={TYPE_COLORS} label={`${value.name} color`} onChange={setColor} /><div><input value={name} onChange={(event) => setName(event.target.value)} /><input className="metadata-description" value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Description (optional)" /></div><button className="button ghost" disabled={busy || !dirty || !name.trim()} onClick={() => void mutate(() => request(`/api/node-types/${value.id}`, { method: "PUT", body: JSON.stringify({ name: name.trim(), description: description.trim() || null, color }) }), "Type updated")}>Save</button><button className="button ghost danger-text" disabled={busy} onClick={() => window.confirm(`Delete type “${value.name}”? Assigned nodes will become untyped.`) && void mutate(() => request(`/api/node-types/${value.id}`, { method: "DELETE" }), "Type deleted")}>Delete</button></div>;
}
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<Record<string, "keep" | "add" | "remove">>({});
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 <Modal title={`Type & tags for ${count} nodes`} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !setType && !addTagIds.length && !removeTagIds.length} onClick={() => onConfirm({ setType, typeId: typeId || null, addTagIds, removeTagIds })}>Apply</button></>}><label className="multiple-option"><input type="checkbox" checked={setType} onChange={(event) => setSetType(event.target.checked)} /><span>Change type for every selected node</span></label>{setType && <label className="field"><span>Type</span><select value={typeId} onChange={(event) => setTypeId(event.target.value)}><option value="">No type</option>{nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>}<div className="field"><span>Tags</span><div className="bulk-tag-actions">{tags.map((tag) => <label key={tag.id}><i style={{ background: tag.color ?? "#888" }} /><span>{tag.name}</span><select value={tagActions[tag.id] ?? "keep"} onChange={(event) => setTagActions((previous) => ({ ...previous, [tag.id]: event.target.value as "keep" | "add" | "remove" }))}><option value="keep">No change</option><option value="add">Add</option><option value="remove">Remove</option></select></label>)}{!tags.length && <small>No tags have been created.</small>}</div></div></Modal>;
}
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<string>();
const collect = (node: ApiNode) => { included.add(node.id); node.children.forEach(collect); }; roots.forEach(collect);
const usedTypeIds = new Set<string>(); const usedTagIds = new Set<string>();
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 <section className="export-workspace"><div className="pane-heading"><div><h1>Export hierarchy</h1><p>Portable, human-readable JSON containing the entire inventory.</p></div></div><div className="export-grid"><div className="card export-controls"><div className="export-summary"><strong>{included.size} nodes</strong><span>{payload.types.length} types · {payload.tags.length} tags</span></div><p className="export-note">Names, descriptions, quantities, hierarchy, types, tags, and lookup-code references are included. Internal UUIDs are omitted.</p><button className="button primary full" disabled={!root || roots.length === 0} onClick={download}><DownloadIcon />Download JSON</button></div><div className="card export-preview"><div className="card-heading"><div><h2>Preview</h2><p>Entire inventory · no internal UUIDs</p></div></div><pre>{json}</pre></div></div></section>;
}
function ImportWorkspace({ nodes, selectedId, busy, mutate }: { nodes: FlatNode[]; selectedId: string | null; busy: boolean; mutate: (action: () => Promise<unknown>, success?: string) => Promise<void> }) {
const [parentId, setParentId] = useState(selectedId ?? "unsorted");
const [manifest, setManifest] = useState("");
const [preview, setPreview] = useState<ImportPreview | null>(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<ImportPreview>("/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 <section className="import-workspace"><div className="pane-heading"><div><h1>Import hierarchy</h1><p>Preview a structured JSON manifest before changing the inventory.</p></div></div>
<div className="import-grid"><form className="card import-form" onSubmit={previewImport}>
<label className="field"><span>Destination</span><select value={parentId} onChange={(event) => { setParentId(event.target.value); clearPreview(); }}>{nodes.map(({ node, depth }) => <option key={node.id} value={node.id}>{"— ".repeat(depth)}{node.name}</option>)}</select></label>
<label className="field grow"><span>JSON manifest</span><textarea value={manifest} onChange={(event) => { setManifest(event.target.value); clearPreview(); }} placeholder={'{\n "nodes": [\n { "name": "Box A", "children": [] }\n ]\n}'} spellCheck={false} /></label>
{error && <p className="form-error">{error}</p>}
<button className="button primary" disabled={!manifest.trim() || busy}>Preview import</button>
</form>
<div className="card preview-card"><div className="card-heading"><div><h2>Preview</h2>{preview && <p>{preview.summary.nodes} nodes · depth {preview.summary.maximumDepth}</p>}</div>{preview && <button className="button ghost" onClick={clearPreview}>Clear</button>}</div>
{preview ? <><ImportTree nodes={preview.manifest.nodes} /><div className="preview-footer"><span>No changes have been made.</span><button className="button primary" disabled={busy} onClick={() => mutate(async () => { const result = await request<{ created: { nodes: number } }>("/api/imports/tree/commit", { method: "POST", body: JSON.stringify({ planId: preview.planId }) }); setManifest(""); setPreview(null); return result; }, `Imported ${preview.summary.nodes} nodes`)}>Import {preview.summary.nodes} nodes</button></div></> : <div className="empty-state compact"><ImportIcon /><h2>Nothing to preview</h2><p>Paste a manifest and select Preview import.</p></div>}
</div></div>
</section>;
}
function ImportTree({ nodes, depth = 0 }: { nodes: ImportNode[]; depth?: number }) {
return <div className="import-tree">{nodes.map((node, index) => <div key={`${depth}-${index}-${node.name}`}><div className="import-row" style={{ paddingLeft: `${12 + depth * 24}px` }}><FolderIcon /><strong>{node.name}</strong>{node.quantity !== undefined && <span>Qty {node.quantity}</span>}</div>{node.children?.length ? <ImportTree nodes={node.children} depth={depth + 1} /> : null}</div>)}</div>;
}
function Modal({ title, onClose, children, footer }: { title: string; onClose: () => void; children: ReactNode; footer: ReactNode }) {
useEffect(() => { const close = (event: KeyboardEvent) => event.key === "Escape" && onClose(); window.addEventListener("keydown", close); return () => window.removeEventListener("keydown", close); }, [onClose]);
return <div className="modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><div className="modal" role="dialog" aria-modal="true" aria-label={title}><div className="modal-header"><h2>{title}</h2><button className="icon-button" onClick={onClose}><CloseIcon /></button></div><div className="modal-body">{children}</div><div className="modal-footer">{footer}</div></div></div>;
}
type ContextAction = "open" | "rename" | "description" | "type" | "tags" | "label" | "individualize" | "combine" | "move" | "delete" | "clear";
function NodeContextMenu({ position, nodes, onClose, onAction }: { position: { x: number; y: number }; nodes: ApiNode[]; onClose: () => void; onAction: (action: ContextAction) => void }) {
const menu = useRef<HTMLDivElement>(null); const single = nodes.length === 1; const node = nodes[0];
const left = Math.min(position.x, window.innerWidth - 230); const top = Math.min(position.y, window.innerHeight - 430);
useEffect(() => { const close = (event: MouseEvent | KeyboardEvent) => { if (event instanceof KeyboardEvent ? event.key === "Escape" : !menu.current?.contains(event.target as globalThis.Node)) onClose(); }; document.addEventListener("mousedown", close); document.addEventListener("keydown", close); return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", close); }; }, [onClose]);
const item = (action: ContextAction, label: string, disabled = false, danger = false) => <button disabled={disabled} className={danger ? "danger" : ""} onClick={() => onAction(action)}>{label}</button>;
return createPortal(<div ref={menu} className="node-context-menu" style={{ left: Math.max(8, left), top: Math.max(8, top) }} role="menu"><div className="context-heading">{single ? node.name : `${nodes.length} nodes selected`}</div>{single && item("open", "Open")}{single && item("rename", "Rename")}{single && item("description", "Change description")}<div className="context-divider" />{item("type", "Edit type")}{item("tags", "Add or remove tags")}<div className="context-divider" />{item("label", nodes.length === 1 ? "Create label" : "Create labels")}{single && node.quantity !== null && node.quantity >= 2 && item("individualize", "Track individually")}{!single && item("combine", "Combine", nodes.length < 2)}{item("move", nodes.length === 1 ? "Move node" : "Move nodes")}<div className="context-divider" />{item("delete", nodes.length === 1 ? "Delete node" : "Delete nodes", nodes.some((item) => item.children.length > 0), true)}{item("clear", "Clear selection")}</div>, document.body);
}
function TextEditDialog({ title, label, value: initial, multiline, busy, onClose, onSave }: { title: string; label: string; value: string; multiline?: boolean; busy: boolean; onClose: () => void; onSave: (value: string) => void }) {
const [value, setValue] = useState(initial); const input = useRef<HTMLInputElement & HTMLTextAreaElement>(null); useEffect(() => input.current?.focus(), []);
return <Modal title={title} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || label === "Name" && !value.trim()} onClick={() => onSave(value.trim())}>Save</button></>}>{multiline ? <label className="field"><span>{label}</span><textarea ref={input} className="description-input" value={value} onChange={(event) => setValue(event.target.value)} /></label> : <label className="field"><span>{label}</span><input ref={input} value={value} onChange={(event) => setValue(event.target.value)} /></label>}</Modal>;
}
function TypeAssignmentDialog({ nodes, nodeTypes, busy, onClose, onSave }: { nodes: ApiNode[]; nodeTypes: NodeType[]; busy: boolean; onClose: () => void; onSave: (typeId: string | null) => void }) {
const common = nodes.every((node) => node.typeId === nodes[0].typeId) ? nodes[0].typeId ?? "" : "mixed"; const [typeId, setTypeId] = useState(common);
return <Modal title={`Edit type for ${nodes.length} ${nodes.length === 1 ? "node" : "nodes"}`} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || typeId === "mixed"} onClick={() => onSave(typeId || null)}>Apply</button></>}><label className="field"><span>Type</span><select value={typeId} onChange={(event) => setTypeId(event.target.value)}>{common === "mixed" && <option value="mixed" disabled>Mixed</option>}<option value="">No type</option>{nodeTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label></Modal>;
}
function TagAssignmentDialog({ nodes, tags, busy, onCreateTag, onUpdateTag, onDeleteTag, onClose, onSave }: { nodes: ApiNode[]; tags: Tag[]; busy: boolean; onCreateTag: (name: string) => Promise<Tag>; onUpdateTag: (tag: Tag) => Promise<Tag>; onDeleteTag: (id: string) => Promise<void>; onClose: () => void; onSave: (addTagIds: string[], removeTagIds: string[]) => void }) {
const [actions, setActions] = useState<Record<string, "add" | "remove">>({}); const [name, setName] = useState(""); const [creating, setCreating] = useState(false);
const original = new Set(nodes[0]?.tagIds ?? []); const [singleIds, setSingleIds] = useState<Set<string>>(new Set(original));
const state = (id: string): "add" | "remove" | "all" | "mixed" | "none" => actions[id] ?? (nodes.every((node) => node.tagIds.includes(id)) ? "all" : nodes.some((node) => node.tagIds.includes(id)) ? "mixed" : "none");
const toggle = (id: string) => setActions((current) => ({ ...current, [id]: state(id) === "all" ? "remove" : "add" }));
const create = async () => { if (!name.trim()) return; setCreating(true); try { const tag = await onCreateTag(name.trim()); setActions((current) => ({ ...current, [tag.id]: "add" })); setName(""); } finally { setCreating(false); } };
const add = nodes.length === 1 ? [...singleIds].filter((id) => !original.has(id)) : Object.entries(actions).filter(([, action]) => action === "add").map(([id]) => id); const remove = nodes.length === 1 ? [...original].filter((id) => !singleIds.has(id)) : Object.entries(actions).filter(([, action]) => action === "remove").map(([id]) => id);
return <Modal title={`Tags for ${nodes.length} ${nodes.length === 1 ? "node" : "nodes"}`} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !add.length && !remove.length} onClick={() => onSave(add, remove)}>Apply</button></>}>{nodes.length === 1 ? <div className="single-tag-editor"><TagSelector tags={tags} selectedIds={singleIds} onChange={setSingleIds} onCreateTag={onCreateTag} onUpdateTag={onUpdateTag} onDeleteTag={onDeleteTag} /></div> : <><div className="inline-tag-create"><input value={name} onChange={(event) => setName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void create(); } }} placeholder="Find or create a tag" /><button className="button ghost" disabled={creating || !name.trim()} onClick={() => void create()}>Create</button></div><div className="context-tag-list">{tags.map((tag) => <button key={tag.id} className={state(tag.id)} onClick={() => toggle(tag.id)}><i style={{ background: tag.color ?? "#888" }} /><span>{tag.name}</span><small>{state(tag.id) === "all" ? "All" : state(tag.id) === "mixed" ? "Some" : state(tag.id) === "add" ? "Add" : state(tag.id) === "remove" ? "Remove" : "None"}</small></button>)}</div></>}</Modal>;
}
function CreateDialog({ parent, busy, onClose, onCreate }: { parent: ApiNode | null; busy: boolean; onClose: () => void; onCreate: (names: string[], description: string | null, quantity: number | null) => void }) {
const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [quantity, setQuantity] = useState(""); const [addMultiple, setAddMultiple] = useState(false); const [trackUnits, setTrackUnits] = useState(false); const input = useRef<HTMLTextAreaElement>(null);
useEffect(() => input.current?.focus(), []);
const looksMultiple = /[\n,]/.test(name);
const unitCount = Number(quantity);
const width = Math.max(2, String(unitCount).length);
const entries = addMultiple ? name.split(/[\n,]+/).map((part) => part.trim()).filter(Boolean) : trackUnits && name.trim() && Number.isInteger(unitCount) && unitCount >= 2 && unitCount <= 1000 ? Array.from({ length: unitCount }, (_, index) => `${name.trim()} ${String(index + 1).padStart(width, "0")}`) : [name.trim()].filter(Boolean);
const valid = entries.length > 0 && (!trackUnits || Number.isInteger(unitCount) && unitCount >= 2 && unitCount <= 1000);
const submit = (event: FormEvent) => { event.preventDefault(); if (valid) onCreate(entries, description.trim() || null, addMultiple || trackUnits ? null : quantity === "" ? null : Number(quantity)); };
return <form onSubmit={submit}><Modal title="New node" onClose={onClose} footer={<><button type="button" className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !valid}>{entries.length > 1 ? `Create ${entries.length} nodes` : "Create node"}</button></>}><p className="modal-intro">Add inside <strong>{parent?.name ?? "Unsorted"}</strong></p><label className="field"><span>{addMultiple ? "Names" : "Name"}</span><textarea ref={input} className="quick-add-input" rows={addMultiple ? 6 : 2} value={name} onChange={(event) => { setName(event.target.value); if (!/[\n,]/.test(event.target.value)) setAddMultiple(false); }} /></label>{looksMultiple && <label className="multiple-option"><input type="checkbox" checked={addMultiple} onChange={(event) => { setAddMultiple(event.target.checked); if (event.target.checked) setTrackUnits(false); }} /><span>Add comma- or line-separated text as {name.split(/[\n,]+/).map((part) => part.trim()).filter(Boolean).length} nodes</span></label>}<label className="field"><span>Description <em>optional</em></span><textarea className="description-input" rows={3} value={description} onChange={(event) => setDescription(event.target.value)} /></label>{!addMultiple && <><label className="field"><span>{trackUnits ? "Number of units" : <>Quantity <em>optional</em></>}</span><input type="number" min={trackUnits ? "2" : "0"} step="1" value={quantity} onChange={(event) => { setQuantity(event.target.value); if (Number(event.target.value) <= 0) setTrackUnits(false); }} /></label>{Number(quantity) > 0 && <label className="multiple-option"><input type="checkbox" checked={trackUnits} onChange={(event) => { setTrackUnits(event.target.checked); if (event.target.checked && Number(quantity) < 2) setQuantity("2"); }} /><span>Track each unit separately with its own lookup code</span></label>}{trackUnits && entries.length > 1 && <NamePreview names={entries} />}</>}</Modal></form>;
}
function IndividualizeDialog({ node, busy, onClose, onConfirm }: { node: ApiNode; busy: boolean; onClose: () => void; onConfirm: (names: string[]) => void }) {
const count = node.quantity ?? 0;
const [baseName, setBaseName] = useState(node.name);
const width = Math.max(2, String(count).length);
const names = count <= 1000 ? Array.from({ length: count }, (_, index) => `${baseName.trim()} ${String(index + 1).padStart(width, "0")}`) : [];
const invalid = node.children.length > 0 || count < 2 || count > 1000 || !baseName.trim();
return <Modal title="Track individually" onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || invalid} onClick={() => onConfirm(names)}>Create {count} nodes</button></>}><p className="modal-intro">Turn quantity {count} into separately identified sibling nodes. The first unit keeps lookup code <strong>{node.lookupCode}</strong>.</p>{node.children.length > 0 && <p className="form-error">This node must be empty before it can be tracked individually.</p>}{count > 1000 && <p className="form-error">At most 1,000 units can be created at once.</p>}<label className="field"><span>Base name</span><input value={baseName} onChange={(event) => setBaseName(event.target.value)} /></label><NamePreview names={names} /></Modal>;
}
function CombineDialog({ nodes, busy, onClose, onConfirm }: { nodes: ApiNode[]; busy: boolean; onClose: () => void; onConfirm: (retainedNodeId: string, name: string) => void }) {
const [retainedNodeId, setRetainedNodeId] = useState(nodes[0].id);
const [name, setName] = useState(nodes[0].name.replace(/\s+\d+$/, ""));
const sameParent = nodes.every((node) => node.parentId === nodes[0].parentId);
const allEmpty = nodes.every((node) => node.children.length === 0);
const quantity = nodes.reduce((total, node) => total + (node.quantity ?? 1), 0);
const retained = nodes.find((node) => node.id === retainedNodeId) ?? nodes[0];
const valid = sameParent && allEmpty && name.trim().length > 0;
return <Modal title="Combine into quantity" onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !valid} onClick={() => onConfirm(retainedNodeId, name.trim())}>Combine nodes</button></>}><p className="modal-intro">Replace {nodes.length} selected nodes with one node having quantity <strong>{quantity}</strong>.</p>{!sameParent && <p className="form-error">All selected nodes must have the same parent.</p>}{!allEmpty && <p className="form-error">Every selected node must be empty.</p>}<label className="field"><span>Combined name</span><input value={name} onChange={(event) => setName(event.target.value)} /></label><label className="field"><span>Identity to retain</span><select value={retainedNodeId} onChange={(event) => setRetainedNodeId(event.target.value)}>{nodes.map((node) => <option key={node.id} value={node.id}>{node.name} · {node.lookupCode}</option>)}</select></label><div className="identity-warning"><strong>{retained.lookupCode}</strong> will remain. The other {nodes.length - 1} lookup {nodes.length - 1 === 1 ? "code" : "codes"} and existing QR labels will stop working.</div></Modal>;
}
function NamePreview({ names }: { names: string[] }) {
return <div className="name-preview"><span>Preview</span><div>{names.slice(0, 20).map((name) => <code key={name}>{name}</code>)}{names.length > 20 && <small>and {names.length - 20} more</small>}</div></div>;
}
function MoveDialog({ node, nodes, busy, onClose, onMove }: { node: ApiNode; nodes: FlatNode[]; busy: boolean; onClose: () => void; onMove: (target: string, handling: ChildHandling) => void }) {
const invalid = descendants(node); const choices = nodes.filter(({ node: candidate }) => !invalid.has(candidate.id));
const [target, setTarget] = useState(choices.find(({ node: candidate }) => candidate.id === "unsorted")?.node.id ?? choices[0]?.node.id ?? "");
const [handling, setHandling] = useState<ChildHandling>("WITH_SUBTREE");
return <Modal title={`Move “${node.name}`} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !target} onClick={() => onMove(target, handling)}>Move node</button></>}><div className="field"><span>Destination</span>{nodes[0] && <MoveTreePicker root={nodes[0].node} forbidden={invalid} selectedId={target} onSelect={setTarget} />}</div>{node.children.length > 0 && <label className="field"><span>Children</span><select value={handling} onChange={(event) => setHandling(event.target.value as ChildHandling)}><option value="WITH_SUBTREE">Move the entire subtree</option><option value="PROMOTE_CHILDREN">Move children up to the current parent</option><option value="CHILDREN_TO_UNSORTED">Move children to Unsorted</option></select></label>}</Modal>;
}
function BulkMoveDialog({ nodesToMove, nodes, busy, onClose, onMove }: { nodesToMove: ApiNode[]; nodes: FlatNode[]; busy: boolean; onClose: () => void; onMove: (target: string, handling: ChildHandling) => void }) {
const invalid = new Set(nodesToMove.flatMap((node) => [...descendants(node)]));
const choices = nodes.filter(({ node }) => !invalid.has(node.id));
const [target, setTarget] = useState(choices.find(({ node }) => node.id === "unsorted")?.node.id ?? choices[0]?.node.id ?? "");
const [handling, setHandling] = useState<ChildHandling>("WITH_SUBTREE");
return <Modal title={`Move ${nodesToMove.length} nodes`} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !target} onClick={() => onMove(target, handling)}>Move nodes</button></>}><div className="field"><span>Destination</span>{nodes[0] && <MoveTreePicker root={nodes[0].node} forbidden={invalid} selectedId={target} onSelect={setTarget} />}</div>{nodesToMove.some((node) => node.children.length > 0) && <label className="field"><span>Children of selected nodes</span><select value={handling} onChange={(event) => setHandling(event.target.value as ChildHandling)}><option value="WITH_SUBTREE">Move each entire subtree</option><option value="PROMOTE_CHILDREN">Move direct children up</option><option value="CHILDREN_TO_UNSORTED">Move direct children to Unsorted</option></select></label>}</Modal>;
}
function DropMoveDialog({ nodesToMove, nodes, initialTarget, busy, onClose, onMove }: { nodesToMove: ApiNode[]; nodes: FlatNode[]; initialTarget: string; busy: boolean; onClose: () => void; onMove: (target: string, handling: ChildHandling) => void }) {
const invalid = new Set(nodesToMove.flatMap((node) => [...descendants(node)]));
const choices = nodes.filter(({ node }) => !invalid.has(node.id));
const [target, setTarget] = useState(choices.some(({ node }) => node.id === initialTarget) ? initialTarget : choices[0]?.node.id ?? "");
const [handling, setHandling] = useState<ChildHandling>("WITH_SUBTREE");
const title = nodesToMove.length === 1 ? `Move “${nodesToMove[0].name}` : `Move ${nodesToMove.length} nodes`;
return <Modal title={title} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={busy || !target} onClick={() => onMove(target, handling)}>Move {nodesToMove.length === 1 ? "node" : "nodes"}</button></>}><div className="field"><span>Destination</span>{nodes[0] && <MoveTreePicker root={nodes[0].node} forbidden={invalid} selectedId={target} onSelect={setTarget} />}</div>{nodesToMove.some((node) => node.children.length > 0) && <label className="field"><span>{nodesToMove.length === 1 ? "Children" : "Children of selected nodes"}</span><select value={handling} onChange={(event) => setHandling(event.target.value as ChildHandling)}><option value="WITH_SUBTREE">Move the entire subtree</option><option value="PROMOTE_CHILDREN">Move children up to the current parent</option><option value="CHILDREN_TO_UNSORTED">Move children to Unsorted</option></select></label>}</Modal>;
}
function MoveTreePicker({ root, forbidden, selectedId, onSelect }: { root: ApiNode; forbidden: Set<string>; selectedId: string; onSelect: (id: string) => void }) {
const selectedPath = findPath(root, selectedId);
const [expanded, setExpanded] = useState<Set<string>>(new Set([root.id, ...selectedPath.slice(0, -1).map((node) => node.id)]));
const toggle = (id: string) => setExpanded((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; });
return <div className="move-tree"><MovePickerRow node={root} depth={0} forbidden={forbidden} selectedId={selectedId} expanded={expanded} onSelect={onSelect} onToggle={toggle} /></div>;
}
function MovePickerRow({ node, depth, forbidden, selectedId, expanded, onSelect, onToggle }: { node: ApiNode; depth: number; forbidden: Set<string>; selectedId: string; expanded: Set<string>; onSelect: (id: string) => void; onToggle: (id: string) => void }) {
if (forbidden.has(node.id)) return null;
const children = node.children.filter((child) => !forbidden.has(child.id));
const open = expanded.has(node.id);
return <><div className={`move-tree-row ${selectedId === node.id ? "selected" : ""}`} style={{ paddingLeft: `${depth * 16}px` }}><button type="button" className={`disclosure ${open ? "open" : ""}`} disabled={!children.length} onClick={() => onToggle(node.id)}><ChevronIcon /></button><button type="button" className="move-tree-node" onClick={() => onSelect(node.id)}><span>{node.name}</span><span className={`radio-dot ${selectedId === node.id ? "selected" : ""}`} /></button></div>{open && children.map((child) => <MovePickerRow key={child.id} node={child} depth={depth + 1} forbidden={forbidden} selectedId={selectedId} expanded={expanded} onSelect={onSelect} onToggle={onToggle} />)}</>;
}
function ConfirmDialog({ title, message, disabled, onClose, onConfirm }: { title: string; message: string; disabled: boolean; onClose: () => void; onConfirm: () => void }) {
return <Modal title={title} onClose={onClose} footer={<><button className="button ghost" onClick={onClose}>Cancel</button><button className="button danger-button" disabled={disabled} onClick={onConfirm}>Delete</button></>}><p className="modal-intro">{message}</p></Modal>;
}
function EmptyDetails() { return <div className="empty-state"><BoxIcon /><h2>Select a node</h2><p>Its properties and actions will appear here.</p></div>; }
function SkeletonRows() { return <div className="skeleton-list"><i/><i/><i/><i/></div>; }
function messageOf(error: unknown) { return error instanceof Error ? error.message : "Something went wrong"; }
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { ApiNode } from "./api";
import { CloseIcon } from "./icons";
import { BUILT_IN_TEMPLATES, DEFAULT_CUSTOM_TEMPLATE, LabelSheetTemplate, PAGE_SIZES, PageKind, validateTemplate } from "./labelTemplates";
export interface LabelEntry {
node: ApiNode;
breadcrumb: string;
}
interface Placement { entry: LabelEntry; slot: number }
function loadCustomTemplate(): LabelSheetTemplate {
try {
const saved = JSON.parse(localStorage.getItem("box-manifest.label.custom-template") ?? "null") as Partial<LabelSheetTemplate> | null;
return saved ? { ...DEFAULT_CUSTOM_TEMPLATE, ...saved, id: "custom", aliases: [] } : DEFAULT_CUSTOM_TEMPLATE;
} catch { return DEFAULT_CUSTOM_TEMPLATE; }
}
function paginate(entries: LabelEntry[], startSlot: number, capacity: number): Placement[][] {
const pages: Placement[][] = [];
let entryIndex = 0;
let firstPage = true;
while (entryIndex < entries.length) {
const placements: Placement[] = [];
for (let slot = firstPage ? startSlot : 0; slot < capacity && entryIndex < entries.length; slot += 1) {
placements.push({ entry: entries[entryIndex], slot });
entryIndex += 1;
}
pages.push(placements);
firstPage = false;
}
return pages;
}
export function LabelSheetDialog({ entries, onClose }: { entries: LabelEntry[]; onClose: () => void }) {
const savedTemplateId = localStorage.getItem("box-manifest.label.template") ?? BUILT_IN_TEMPLATES[0].id;
const savedTemplate = BUILT_IN_TEMPLATES.find((candidate) => candidate.id === savedTemplateId);
const [pageKind, setPageKind] = useState<PageKind>(savedTemplate?.pageKind ?? (savedTemplateId === "custom" ? loadCustomTemplate().pageKind : "letter"));
const [templateId, setTemplateId] = useState(savedTemplate?.id ?? (savedTemplateId === "custom" ? "custom" : BUILT_IN_TEMPLATES[0].id));
const [customTemplate, setCustomTemplate] = useState<LabelSheetTemplate>(loadCustomTemplate);
const template = templateId === "custom" ? customTemplate : BUILT_IN_TEMPLATES.find((candidate) => candidate.id === templateId) ?? BUILT_IN_TEMPLATES[0];
const capacity = template.rows * template.columns;
const [startSlot, setStartSlot] = useState(0);
const [qrUrls, setQrUrls] = useState<Map<string, string>>(new Map());
const [generating, setGenerating] = useState(false);
const [error, setError] = useState("");
const validationErrors = validateTemplate(template);
const validCapacity = Number.isInteger(capacity) && capacity > 0 && capacity <= 1000;
const pages = useMemo(() => validCapacity && validationErrors.length === 0 ? paginate(entries, Math.min(startSlot, capacity - 1), capacity) : [], [entries, startSlot, capacity, validCapacity, validationErrors.length]);
const availableTemplates = BUILT_IN_TEMPLATES.filter((candidate) => candidate.pageKind === pageKind);
useEffect(() => {
localStorage.setItem("box-manifest.label.template", templateId);
localStorage.setItem("box-manifest.label.custom-template", JSON.stringify(customTemplate));
}, [customTemplate, templateId]);
useEffect(() => { if (startSlot >= capacity) setStartSlot(0); }, [capacity, startSlot]);
useEffect(() => {
const close = (event: KeyboardEvent) => event.key === "Escape" && onClose();
window.addEventListener("keydown", close);
return () => window.removeEventListener("keydown", close);
}, [onClose]);
useEffect(() => {
let active = true;
setError("");
void Promise.all(entries.map(async ({ node }) => [node.id, await QRCode.toDataURL(`${window.location.origin}/n/${node.lookupCode}`, { width: 320, margin: 1, errorCorrectionLevel: "M" })] as const))
.then((pairs) => { if (active) setQrUrls(new Map(pairs)); })
.catch((cause) => { if (active) setError(cause instanceof Error ? cause.message : "Could not generate QR codes"); });
return () => { active = false; };
}, [entries]);
const choosePage = (nextPage: PageKind) => {
setPageKind(nextPage);
if (nextPage === "custom") {
setTemplateId("custom");
setCustomTemplate((current) => ({ ...current, pageKind: "custom" }));
return;
}
if (templateId === "custom") {
const page = PAGE_SIZES[nextPage];
setCustomTemplate((current) => ({ ...current, pageKind: nextPage, pageWidthMm: page.width, pageHeightMm: page.height }));
} else {
setTemplateId(BUILT_IN_TEMPLATES.find((candidate) => candidate.pageKind === nextPage)?.id ?? "custom");
}
};
const chooseTemplate = (id: string) => {
setTemplateId(id);
if (id === "custom" && pageKind !== "custom") {
const page = PAGE_SIZES[pageKind];
setCustomTemplate((current) => ({ ...current, pageKind, pageWidthMm: page.width, pageHeightMm: page.height }));
}
setStartSlot(0);
};
const updateCustom = (field: keyof LabelSheetTemplate, value: number) => setCustomTemplate((current) => ({ ...current, [field]: value }));
const downloadPdf = async () => {
setGenerating(true);
setError("");
try {
const { jsPDF } = await import("jspdf");
if (validationErrors.length) throw new Error(validationErrors[0]);
const orientation = template.pageWidthMm > template.pageHeightMm ? "landscape" : "portrait";
const pdf = new jsPDF({ unit: "mm", format: [template.pageWidthMm, template.pageHeightMm], orientation, compress: true });
pages.forEach((placements, pageIndex) => {
if (pageIndex > 0) pdf.addPage([template.pageWidthMm, template.pageHeightMm], orientation);
placements.forEach(({ entry, slot }) => {
const row = Math.floor(slot / template.columns);
const column = slot % template.columns;
const x = template.marginLeftMm + column * (template.labelWidthMm + template.horizontalGapMm);
const y = template.marginTopMm + row * (template.labelHeightMm + template.verticalGapMm);
const qr = qrUrls.get(entry.node.id);
if (!qr) return;
const padding = 2.5;
const qrSize = Math.min(template.labelHeightMm - padding * 2, template.labelWidthMm * .38);
pdf.addImage(qr, "PNG", x + padding, y + padding, qrSize, qrSize, undefined, "FAST");
const textX = x + qrSize + padding * 2;
const textWidth = template.labelWidthMm - qrSize - padding * 3;
pdf.setTextColor(23, 23, 23);
pdf.setFont("helvetica", "bold");
pdf.setFontSize(9);
pdf.text(pdf.splitTextToSize(entry.node.name, textWidth).slice(0, 2), textX, y + 7);
pdf.setFont("helvetica", "normal");
pdf.setFontSize(5.5);
pdf.setTextColor(85, 85, 80);
pdf.text(pdf.splitTextToSize(entry.breadcrumb, textWidth).slice(0, 2), textX, y + 14);
pdf.setFont("courier", "bold");
pdf.setFontSize(7);
pdf.setTextColor(23, 23, 23);
pdf.text(entry.node.lookupCode, textX, y + template.labelHeightMm - 3.5);
});
});
pdf.save(`box-manifest-labels-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not generate PDF");
} finally {
setGenerating(false);
}
};
return <div className="modal-backdrop label-sheet-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<section className="label-sheet-dialog" role="dialog" aria-modal="true" aria-label="Create label sheet">
<header className="modal-header"><div><h2>Create label sheet</h2><p>{entries.length} {entries.length === 1 ? "node" : "nodes"} · {pages.length} {pages.length === 1 ? "page" : "pages"}</p></div><button className="icon-button" onClick={onClose} aria-label="Close"><CloseIcon /></button></header>
<div className="label-sheet-content">
<aside className="label-sheet-controls">
<label className="field"><span>Paper size</span><select value={pageKind} onChange={(event) => choosePage(event.target.value as PageKind)}><option value="letter">US Letter · 215.9 × 279.4 mm</option><option value="a4">A4 · 210 × 297 mm</option><option value="custom">Custom page size</option></select></label>
<label className="field"><span>Sheet template</span><select value={templateId} onChange={(event) => chooseTemplate(event.target.value)}>{availableTemplates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name} ({candidate.aliases[0]})</option>)}<option value="custom">Custom template</option></select></label>
<p className="template-detail">{template.columns} columns × {template.rows} rows · {template.labelWidthMm.toFixed(1)} × {template.labelHeightMm.toFixed(1)} mm{template.aliases.length > 0 && <><br />Compatible: {template.aliases.join(", ")}</>}</p>
{templateId === "custom" && <CustomTemplateEditor template={customTemplate} customPage={pageKind === "custom"} onChange={updateCustom} />}
{validationErrors.map((message) => <p className="form-error" key={message}>{message}</p>)}
<div className="start-position-summary"><span>Starting position</span><strong>Label {Math.min(startSlot, Math.max(0, capacity - 1)) + 1}</strong><small>Row {Math.floor(Math.min(startSlot, Math.max(0, capacity - 1)) / template.columns) + 1}, column {(Math.min(startSlot, Math.max(0, capacity - 1)) % template.columns) + 1}</small></div>
<p className="template-detail">Click a position on the first page. Earlier positions will remain unused.</p>
<div className="print-note"><strong>Printing</strong><span>Print the downloaded PDF at 100% or Actual Size. Disable Fit to page.</span></div>
{error && <p className="form-error">{error}</p>}
</aside>
<div className="sheet-preview-area">
{pages.map((placements, pageIndex) => <SheetPreview key={pageIndex} template={template} placements={placements} qrUrls={qrUrls} pageNumber={pageIndex + 1} startSlot={pageIndex === 0 ? startSlot : 0} onStartSlotChange={pageIndex === 0 ? setStartSlot : undefined} />)}
{validationErrors.length > 0 && <div className="invalid-sheet-preview">Fix the template dimensions to preview this sheet.</div>}
</div>
</div>
<footer className="modal-footer"><button className="button ghost" onClick={onClose}>Cancel</button><button className="button primary" disabled={generating || qrUrls.size !== entries.length || validationErrors.length > 0} onClick={() => void downloadPdf()}>{generating ? "Generating…" : "Download PDF"}</button></footer>
</section>
</div>;
}
function CustomTemplateEditor({ template, customPage, onChange }: { template: LabelSheetTemplate; customPage: boolean; onChange: (field: keyof LabelSheetTemplate, value: number) => void }) {
return <div className="custom-template-editor">
{customPage && <><NumberField label="Page width" value={template.pageWidthMm} onChange={(value) => onChange("pageWidthMm", value)} /><NumberField label="Page height" value={template.pageHeightMm} onChange={(value) => onChange("pageHeightMm", value)} /></>}
<NumberField label="Rows" value={template.rows} integer onChange={(value) => onChange("rows", value)} />
<NumberField label="Columns" value={template.columns} integer onChange={(value) => onChange("columns", value)} />
<NumberField label="Label width" value={template.labelWidthMm} onChange={(value) => onChange("labelWidthMm", value)} />
<NumberField label="Label height" value={template.labelHeightMm} onChange={(value) => onChange("labelHeightMm", value)} />
<NumberField label="Top margin" value={template.marginTopMm} onChange={(value) => onChange("marginTopMm", value)} />
<NumberField label="Left margin" value={template.marginLeftMm} onChange={(value) => onChange("marginLeftMm", value)} />
<NumberField label="Horizontal gap" value={template.horizontalGapMm} onChange={(value) => onChange("horizontalGapMm", value)} />
<NumberField label="Vertical gap" value={template.verticalGapMm} onChange={(value) => onChange("verticalGapMm", value)} />
</div>;
}
function NumberField({ label, value, integer = false, onChange }: { label: string; value: number; integer?: boolean; onChange: (value: number) => void }) {
return <label><span>{label}{!integer && " (mm)"}</span><input type="number" min="0" max={integer ? 100 : undefined} step={integer ? 1 : .01} value={value} onChange={(event) => onChange(Number(event.target.value))} /></label>;
}
function SheetPreview({ template, placements, qrUrls, pageNumber, startSlot, onStartSlotChange }: { template: LabelSheetTemplate; placements: Placement[]; qrUrls: Map<string, string>; pageNumber: number; startSlot: number; onStartSlotChange?: (slot: number) => void }) {
const bySlot = new Map(placements.map((placement) => [placement.slot, placement]));
return <figure className="sheet-preview"><div className="sheet-page" style={{ aspectRatio: `${template.pageWidthMm} / ${template.pageHeightMm}` }}>
{Array.from({ length: template.rows * template.columns }, (_, slot) => {
const row = Math.floor(slot / template.columns);
const column = slot % template.columns;
const x = template.marginLeftMm + column * (template.labelWidthMm + template.horizontalGapMm);
const y = template.marginTopMm + row * (template.labelHeightMm + template.verticalGapMm);
const placement = bySlot.get(slot);
const entry = placement?.entry;
const className = `sheet-label ${onStartSlotChange ? "selectable" : ""} ${slot < startSlot ? "used-position" : ""} ${slot === startSlot && onStartSlotChange ? "start-position" : ""} ${entry ? "assigned" : "empty-position"}`;
const style = { left: `${x / template.pageWidthMm * 100}%`, top: `${y / template.pageHeightMm * 100}%`, width: `${template.labelWidthMm / template.pageWidthMm * 100}%`, height: `${template.labelHeightMm / template.pageHeightMm * 100}%` };
const content = entry ? <>{qrUrls.get(entry.node.id) && <img src={qrUrls.get(entry.node.id)} alt="" />}<span><strong>{entry.node.name}</strong><small>{entry.breadcrumb}</small><code>{entry.node.lookupCode}</code></span></> : <span className="slot-number">{slot + 1}</span>;
return onStartSlotChange ? <button type="button" className={className} key={slot} style={style} onClick={() => onStartSlotChange(slot)} title={`Start at label ${slot + 1} — row ${row + 1}, column ${column + 1}`} aria-label={`Start at label ${slot + 1}, row ${row + 1}, column ${column + 1}`}>{content}</button> : <div className={className} key={slot} style={style}>{content}</div>;
})}
</div><figcaption>Page {pageNumber}</figcaption></figure>;
}
+41
View File
@@ -0,0 +1,41 @@
export interface ApiNode {
id: string;
lookupCode: string;
parentId: string | null;
name: string;
description: string | null;
quantity: number | null;
typeId: string | null;
tagIds: string[];
children: ApiNode[];
}
export interface NodeType { id: string; name: string; description: string | null; iconKey: string | null; color: string | null }
export interface Tag { id: string; name: string; color: string | null }
export interface ImportNode {
name: string;
description?: string;
quantity?: number;
children?: ImportNode[];
}
export interface ImportPreview {
planId: string;
summary: { nodes: number; maximumDepth: number };
warnings: string[];
manifest: { nodes: ImportNode[] };
}
interface ApiError { message?: string }
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
headers: init.body ? { "Content-Type": "application/json", ...init.headers } : init.headers
});
if (response.status === 204) return undefined as T;
const body = (await response.json()) as T & ApiError;
if (!response.ok) throw new Error(body.message ?? `Request failed (${response.status})`);
return body;
}
+20
View File
@@ -0,0 +1,20 @@
import type { SVGProps } from "react";
type Props = SVGProps<SVGSVGElement>;
export const BoxIcon = (props: Props) => <svg viewBox="0 0 512 512" fill="currentColor" aria-hidden="true" {...props}><path d="M440.9,136.3a4,4,0,0,0,0-6.91L288.16,40.65a64.14,64.14,0,0,0-64.33,0L71.12,129.39a4,4,0,0,0,0,6.91L254,243.88a4,4,0,0,0,4.06,0Z"/><path d="M54,163.51A4,4,0,0,0,48,167V340.89a48,48,0,0,0,23.84,41.39L234,479.51a4,4,0,0,0,6-3.46V274.3a4,4,0,0,0-2-3.46Z"/><path d="M272,275v201a4,4,0,0,0,6,3.46l162.15-97.23A48,48,0,0,0,464,340.89V167a4,4,0,0,0-6-3.45l-184,108A4,4,0,0,0,272,275Z"/></svg>;
export const SearchIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M8.5 2.5C11.8137 2.5 14.5 5.18629 14.5 8.5C14.5 9.79977 14.0852 11.0019 13.3828 11.9844L17.2227 15.8242C17.6132 16.2147 17.6132 16.8478 17.2227 17.2383C16.8321 17.6288 16.1991 17.6288 15.8086 17.2383L11.9658 13.3955C10.9867 14.0899 9.79171 14.5 8.5 14.5C5.18629 14.5 2.5 11.8137 2.5 8.5C2.5 5.18629 5.18629 2.5 8.5 2.5ZM8.5 4C6.01472 4 4 6.01472 4 8.5C4 10.9853 6.01472 13 8.5 13C10.9853 13 13 10.9853 13 8.5C13 6.01472 10.9853 4 8.5 4Z" fill="currentColor"/></svg>;
export const PlusIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M10 3C10.4142 3 10.75 3.33579 10.75 3.75V9.25H16.25C16.6642 9.25 17 9.58579 17 10C17 10.4142 16.6642 10.75 16.25 10.75H10.75V16.25C10.75 16.6642 10.4142 17 10 17C9.58579 17 9.25 16.6642 9.25 16.25V10.75H3.75C3.33579 10.75 3 10.4142 3 10C3 9.58579 3.33579 9.25 3.75 9.25H9.25V3.75C9.25 3.33579 9.58579 3 10 3Z" fill="currentColor"/></svg>;
export const EditSquareIcon = (props: Props) => <svg viewBox="0 0 21 20" fill="none" aria-hidden="true" {...props}><path d="M11.1465 4.49988H5.125C4.84888 4.49988 4.62504 4.72377 4.625 4.99988V14.9999C4.625 15.276 4.84886 15.4999 5.125 15.4999H15.125C15.4011 15.4999 15.625 15.276 15.625 14.9999V8.98425L17.125 7.48425V14.9999C17.125 16.0355 16.3378 16.8869 15.3291 16.9891L15.125 16.9999H5.125L4.9209 16.9891C3.97935 16.8937 3.2312 16.1455 3.13574 15.204L3.125 14.9999V4.99988C3.12504 3.89534 4.02046 2.99988 5.125 2.99988H12.6465L11.1465 4.49988ZM17.0557 4.44812L11.1943 10.3104L9.125 10.9999L9.81445 8.93152L15.6768 3.06921L17.0557 4.44812ZM17.0557 1.68933C17.4365 1.30846 18.0547 1.30849 18.4355 1.68933C18.8164 2.07022 18.8164 2.68833 18.4355 3.06921L17.7461 3.75867L16.3662 2.37976L17.0557 1.68933Z" fill="currentColor"/></svg>;
export const ChevronIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M8 14.5L12.5 10L8 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>;
export const ImportIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M4 17.25C4 16.8358 4.33579 16.5 4.75 16.5H15.25C15.6642 16.5 16 16.8358 16 17.25C16 17.6642 15.6642 18 15.25 18H4.75C4.33579 18 4 17.6642 4 17.25Z" fill="currentColor"/><path d="M10 14C9.58579 14 9.25 13.6642 9.25 13.25V3.75C9.25 3.33579 9.58579 3 10 3C10.4142 3 10.75 3.33579 10.75 3.75V13.25C10.75 13.6642 10.4142 14 10 14Z" fill="currentColor"/><path fillRule="evenodd" clipRule="evenodd" d="M10 1.93933L15.5303 7.46966C15.8232 7.76255 15.8232 8.23743 15.5303 8.53032C15.2374 8.82321 14.7626 8.82321 14.4697 8.53032L10 4.06065L5.53033 8.53032C5.23744 8.82321 4.76256 8.82321 4.46967 8.53032C4.17678 8.23743 4.17678 7.76255 4.46967 7.46966L10 1.93933Z" fill="currentColor"/></svg>;
export const ExportIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><rect x="2.75" y="2.76562" width="14.5" height="14.5" rx="3.25" stroke="currentColor" strokeWidth="1.5"/><path d="M10.75 6C10.75 5.58579 10.4142 5.25 10 5.25C9.58579 5.25 9.25 5.58579 9.25 6H10.75ZM10 14L9.46967 14.5303L10 15.0607L10.5303 14.5303L10 14ZM6.53033 9.46967C6.23744 9.17678 5.76256 9.17678 5.46967 9.46967C5.17678 9.76256 5.17678 10.2374 5.46967 10.5303L6.53033 9.46967ZM14.5303 10.5303C14.8232 10.2374 14.8232 9.76256 14.5303 9.46967C14.2374 9.17678 13.7626 9.17678 13.4697 9.46967L14.5303 10.5303ZM9.25 6V14H10.75V6H9.25ZM10.5303 13.4697L6.53033 9.46967L5.46967 10.5303L9.46967 14.5303L10.5303 13.4697ZM10.5303 14.5303L14.5303 10.5303L13.4697 9.46967L9.46967 13.4697L10.5303 14.5303Z" fill="currentColor"/></svg>;
export const DownloadIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M4 17.25C4 16.8358 4.33579 16.5 4.75 16.5H15.25C15.6642 16.5 16 16.8358 16 17.25C16 17.6642 15.6642 18 15.25 18H4.75C4.33579 18 4 17.6642 4 17.25Z" fill="currentColor"/><path d="M10 1.93945C9.58579 1.93945 9.25 2.27524 9.25 2.68945V12.1895C9.25 12.6037 9.58579 12.9395 10 12.9395C10.4142 12.9395 10.75 12.6037 10.75 12.1895V2.68945C10.75 2.27524 10.4142 1.93945 10 1.93945Z" fill="currentColor"/><path fillRule="evenodd" clipRule="evenodd" d="M10 14L15.5303 8.46967C15.8232 8.17678 15.8232 7.7019 15.5303 7.40901C15.2374 7.11612 14.7626 7.11612 14.4697 7.40901L10 11.8787L5.53033 7.40901C5.23744 7.11612 4.76256 7.11612 4.46967 7.40901C4.17678 7.7019 4.17678 8.17678 4.46967 8.46967L10 14Z" fill="currentColor"/></svg>;
export const CloseIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path fillRule="evenodd" clipRule="evenodd" d="M4.46967 4.46967C4.76256 4.17678 5.23744 4.17678 5.53033 4.46967L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L4.46967 5.53033C4.17678 5.23744 4.17678 4.76256 4.46967 4.46967Z" fill="currentColor"/><path fillRule="evenodd" clipRule="evenodd" d="M15.5303 4.46967C15.2374 4.17678 14.7626 4.17678 14.4697 4.46967L4.46967 14.4697C4.17678 14.7626 4.17678 15.2374 4.46967 15.5303C4.76256 15.8232 5.23744 15.8232 5.53033 15.5303L15.5303 5.53033C15.8232 5.23744 15.8232 4.76256 15.5303 4.46967Z" fill="currentColor"/></svg>;
export const FolderIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M16.6663 14.6667C16.6663 15.0203 16.5259 15.3594 16.2758 15.6095C16.0258 15.8595 15.6866 16 15.333 16H4.66634C4.31272 16 3.97358 15.8595 3.72353 15.6095C3.47348 15.3594 3.33301 15.0203 3.33301 14.6667V5.33333C3.33301 4.97971 3.47348 4.64057 3.72353 4.39052C3.97358 4.14048 4.31272 4 4.66634 4H7.46449C7.79884 4 8.11108 4.1671 8.29654 4.4453L9.03614 5.5547C9.22161 5.8329 9.53384 6 9.86819 6H15.333C15.6866 6 16.0258 6.14048 16.2758 6.39052C16.5259 6.64057 16.6663 6.97971 16.6663 7.33333V14.6667Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>;
export const InventoryIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M2.50006 6.02477C2.50002 6.00825 2.50194 5.99204 2.50671 5.97622C2.57451 5.75136 3.1926 4.00012 6 4C8.80724 4.00012 9.42542 5.75116 9.49328 5.97618C9.49806 5.99203 9.49998 6.00826 9.49993 6.02482L9.47126 15.6271C9.47063 15.8379 9.11667 15.9464 8.97392 15.7914C8.45628 15.2294 7.50596 14.5189 6 14.5C4.49635 14.4999 3.54664 15.2121 3.02847 15.7798C2.886 15.9359 2.52888 15.8277 2.5281 15.6163L2.51562 12.2343L2.50006 6.02477Z" fill="currentColor"/><path d="M10.5001 6.02482C10.5 6.00826 10.5019 5.99203 10.5067 5.97618C10.5746 5.75116 11.1928 4.00012 14 4C16.8072 4.00012 17.4254 5.75116 17.4933 5.97618C17.4981 5.99203 17.5 6.00826 17.4999 6.02482L17.4713 15.6271C17.4706 15.8379 17.1167 15.9464 16.9739 15.7914C16.4563 15.2294 15.506 14.5189 14 14.5C12.4972 14.4999 11.5478 15.2113 11.0294 15.7788C10.8868 15.9349 10.5293 15.8264 10.5287 15.615L10.5001 6.02482Z" fill="currentColor"/></svg>;
export const TypeIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path fillRule="evenodd" clipRule="evenodd" d="M14 3.5H6C4.61929 3.5 3.5 4.61929 3.5 6V14C3.5 15.3807 4.61929 16.5 6 16.5H14C15.3807 16.5 16.5 15.3807 16.5 14V6C16.5 4.61929 15.3807 3.5 14 3.5ZM6 2C3.79086 2 2 3.79086 2 6V14C2 16.2091 3.79086 18 6 18H14C16.2091 18 18 16.2091 18 14V6C18 3.79086 16.2091 2 14 2H6Z" fill="currentColor"/><path d="M9.25 6H10.75V14H9.25V6Z" fill="currentColor"/><path d="M6.5 6H13.5V7.2H6.5V6Z" fill="currentColor"/></svg>;
export const MoreIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M10 11.5C10.8284 11.5 11.5 10.8284 11.5 10C11.5 9.17157 10.8284 8.5 10 8.5C9.17157 8.5 8.5 9.17157 8.5 10C8.5 10.8284 9.17157 11.5 10 11.5Z" fill="currentColor"/><path d="M4 11.5C4.82843 11.5 5.5 10.8284 5.5 10C5.5 9.17157 4.82843 8.5 4 8.5C3.17157 8.5 2.5 9.17157 2.5 10C2.5 10.8284 3.17157 11.5 4 11.5Z" fill="currentColor"/><path d="M16 11.5C16.8284 11.5 17.5 10.8284 17.5 10C17.5 9.17157 16.8284 8.5 16 8.5C15.1716 8.5 14.5 9.17157 14.5 10C14.5 10.8284 15.1716 11.5 16 11.5Z" fill="currentColor"/></svg>;
export const CheckIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path fillRule="evenodd" clipRule="evenodd" d="M14.6104 6.43533L9.09576 14.1558L5.46973 10.5297L6.53039 9.46907L8.90435 11.843L13.3898 5.56348L14.6104 6.43533Z" fill="currentColor"/><path fillRule="evenodd" clipRule="evenodd" d="M14 3.49902H6C4.61929 3.49902 3.5 4.61831 3.5 5.99902V13.999C3.5 15.3797 4.61929 16.499 6 16.499H14C15.3807 16.499 16.5 15.3797 16.5 13.999V5.99902C16.5 4.61831 15.3807 3.49902 14 3.49902ZM6 1.99902C3.79086 1.99902 2 3.78988 2 5.99902V13.999C2 16.2082 3.79086 17.999 6 17.999H14C16.2091 17.999 18 16.2082 18 13.999V5.99902C18 3.78988 16.2091 1.99902 14 1.99902H6Z" fill="currentColor"/></svg>;
export const FilterIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path fillRule="evenodd" clipRule="evenodd" d="M3 6C3 5.58579 3.29381 5.25 3.65625 5.25H16.3438C16.7062 5.25 17 5.58579 17 6C17 6.41421 16.7062 6.75 16.3438 6.75H3.65625C3.29381 6.75 3 6.41421 3 6ZM6 10C6 9.58579 6.26863 9.25 6.6 9.25H13.4C13.7314 9.25 14 9.58579 14 10C14 10.4142 13.7314 10.75 13.4 10.75H6.6C6.26863 10.75 6 10.4142 6 10ZM8.75 13.25C8.33579 13.25 8 13.5858 8 14C8 14.4142 8.33579 14.75 8.75 14.75H11.25C11.6642 14.75 12 14.4142 12 14C12 13.5858 11.6642 13.25 11.25 13.25H8.75Z" fill="currentColor"/></svg>;
export const FilterPlusIcon = (props: Props) => <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}><path d="M10 4C10.4142 4 10.75 4.33579 10.75 4.75V9.25H15.25C15.6642 9.25 16 9.58579 16 10C16 10.4142 15.6642 10.75 15.25 10.75H10.75V15.25C10.75 15.6642 10.4142 16 10 16C9.58579 16 9.25 15.6642 9.25 15.25V10.75H4.75C4.33579 10.75 4 10.4142 4 10C4 9.58579 4.33579 9.25 4.75 9.25H9.25V4.75C9.25 4.33579 9.58579 4 10 4Z" fill="currentColor"/></svg>;
+69
View File
@@ -0,0 +1,69 @@
export type PageKind = "letter" | "a4" | "custom";
export interface LabelSheetTemplate {
id: string;
name: string;
pageKind: PageKind;
aliases: string[];
pageWidthMm: number;
pageHeightMm: number;
rows: number;
columns: number;
labelWidthMm: number;
labelHeightMm: number;
marginTopMm: number;
marginLeftMm: number;
horizontalGapMm: number;
verticalGapMm: number;
}
export const PAGE_SIZES: Record<PageKind, { name: string; width: number; height: number }> = {
letter: { name: "US Letter", width: 215.9, height: 279.4 },
a4: { name: "A4", width: 210, height: 297 },
custom: { name: "Custom", width: 215.9, height: 279.4 },
};
export const BUILT_IN_TEMPLATES: LabelSheetTemplate[] = [
{
id: "letter-address-30", name: "Address labels · 30 per sheet", pageKind: "letter",
aliases: ["Avery 5160", "8160", "18160"], pageWidthMm: 215.9, pageHeightMm: 279.4,
rows: 10, columns: 3, labelWidthMm: 66.675, labelHeightMm: 25.4,
marginTopMm: 12.7, marginLeftMm: 4.7625, horizontalGapMm: 3.175, verticalGapMm: 0,
},
{
id: "letter-address-20", name: "Wide address labels · 20 per sheet", pageKind: "letter",
aliases: ["Avery 5161", "8161"], pageWidthMm: 215.9, pageHeightMm: 279.4,
rows: 10, columns: 2, labelWidthMm: 101.6, labelHeightMm: 25.4,
marginTopMm: 12.7, marginLeftMm: 4.7625, horizontalGapMm: 3.175, verticalGapMm: 0,
},
{
id: "letter-shipping-10", name: "Shipping labels · 10 per sheet", pageKind: "letter",
aliases: ["Avery 5163", "8163"], pageWidthMm: 215.9, pageHeightMm: 279.4,
rows: 5, columns: 2, labelWidthMm: 101.6, labelHeightMm: 50.8,
marginTopMm: 12.7, marginLeftMm: 4.7625, horizontalGapMm: 3.175, verticalGapMm: 0,
},
{
id: "a4-address-21", name: "Address labels · 21 per sheet", pageKind: "a4",
aliases: ["Avery L7160"], pageWidthMm: 210, pageHeightMm: 297,
rows: 7, columns: 3, labelWidthMm: 63.5, labelHeightMm: 38.1,
marginTopMm: 15.15, marginLeftMm: 7.25, horizontalGapMm: 2.5, verticalGapMm: 0,
},
];
export const DEFAULT_CUSTOM_TEMPLATE: LabelSheetTemplate = {
...BUILT_IN_TEMPLATES[0], id: "custom", name: "Custom template", pageKind: "letter", aliases: [],
};
export function validateTemplate(template: LabelSheetTemplate): string[] {
const errors: string[] = [];
const positive = [template.pageWidthMm, template.pageHeightMm, template.rows, template.columns, template.labelWidthMm, template.labelHeightMm];
if (positive.some((value) => !Number.isFinite(value) || value <= 0)) errors.push("Page, grid, and label dimensions must be greater than zero.");
if (![template.rows, template.columns].every(Number.isInteger)) errors.push("Rows and columns must be whole numbers.");
if (template.rows * template.columns > 1000) errors.push("A sheet cannot contain more than 1,000 label positions.");
if ([template.marginTopMm, template.marginLeftMm, template.horizontalGapMm, template.verticalGapMm].some((value) => !Number.isFinite(value) || value < 0)) errors.push("Margins and gaps cannot be negative.");
const right = template.marginLeftMm + template.columns * template.labelWidthMm + (template.columns - 1) * template.horizontalGapMm;
const bottom = template.marginTopMm + template.rows * template.labelHeightMm + (template.rows - 1) * template.verticalGapMm;
if (right > template.pageWidthMm + 0.01) errors.push(`Labels exceed the page width by ${(right - template.pageWidthMm).toFixed(2)} mm.`);
if (bottom > template.pageHeightMm + 0.01) errors.push(`Labels exceed the page height by ${(bottom - template.pageHeightMm).toFixed(2)} mm.`);
return errors;
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "@fontsource/inter/latin-400.css";
import "@fontsource/inter/latin-500.css";
import "@fontsource/inter/latin-600.css";
import "@fontsource/inter/latin-700.css";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+483
View File
@@ -0,0 +1,483 @@
:root {
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #171717;
background: #fcfcfa;
font-synthesis: none;
text-rendering: optimizeLegibility;
--canvas: #fcfcfa;
--panel: #fcfcfa;
--sidebar: #f1f1ed;
--line: #e3e3de;
--line-strong: #cecec8;
--muted: #6f6f6a;
--soft: #f1f1ed;
--hover: #ebebe7;
--selected: #f1f1ed;
--ink: #171717;
--accent: #363636;
--danger: #ba3f3f;
--system-accent: #377aff;
--system-selection: rgba(55, 122, 255, 0.25);
--radius: 10px;
--ease-reference: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
svg { width: 18px; height: 18px; flex: none; }
:focus-visible { outline: 2px solid var(--system-accent); outline-offset: 2px; }
.app-shell { display: grid; grid-template-columns: 144px minmax(0, 1fr); width: 100vw; height: 100vh; background: var(--canvas); }
.sidebar { display: flex; flex-direction: column; min-width: 0; padding: 0 8px 8px; overflow: hidden; background: var(--panel); }
.brand { display: flex; align-items: center; gap: 8px; height: 52px; padding: 0 8px; font-size: 14px; line-height: 22px; font-weight: 600; letter-spacing: -.12px; }
.brand-mark { display: grid; place-items: center; width: 28px; height: 28px; color: white; background: #30302d; border-radius: 7px; }
.brand-mark svg { width: 16px; }
.nav-list { display: grid; gap: 4px; margin: 8px 4px 20px; }
.nav-item { display: flex; align-items: center; gap: 8px; width: 100%; height: 36px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font-size: 14px; line-height: 22px; letter-spacing: -.12px; text-align: left; cursor: pointer; transition: background 150ms var(--ease-reference); }
.nav-item svg { width: 16px; color: #686862; }
.nav-item:hover { background: var(--hover); }
.nav-item.active { background: var(--selected); font-weight: 600; }
.section-label { padding: 0 9px 7px; color: #85857f; font-size: 11px; font-weight: 650; letter-spacing: .045em; text-transform: uppercase; }
.workspace { position: relative; display: flex; min-width: 0; flex-direction: column; overflow: hidden; }
.topbar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; height: 52px; padding: 0 12px; background: color-mix(in srgb, var(--canvas) 95%, transparent); }
.history-actions { grid-column: 1; display: flex; align-items: center; gap: 2px; }
.history-button { display: grid; width: 30px; height: 30px; padding: 0; place-items: center; color: var(--ink); border: 0; border-radius: 15px; background: transparent; cursor: pointer; }
.history-button:hover:not(:disabled) { background: var(--soft); }
.history-button:disabled { color: var(--muted); opacity: .35; cursor: default; }
.history-button svg { width: 15px; }
.history-button.back svg { transform: rotate(180deg); }
.breadcrumbs, .breadcrumb-part { display: flex; min-width: 0; align-items: center; gap: 5px; color: var(--muted); font-size: 12px; line-height: 18px; }
.breadcrumbs { grid-column: 2; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
.breadcrumbs::-webkit-scrollbar { display: none; }
.breadcrumb-part, .breadcrumbs > button, .breadcrumbs > span, .breadcrumbs > strong { flex: none; }
.breadcrumbs svg { width: 13px; }
.breadcrumbs button { padding: 3px; border: 0; background: none; white-space: nowrap; cursor: pointer; }
.breadcrumbs button.active { color: var(--ink); font-weight: 600; }
.breadcrumbs button.breadcrumb-drop-target { color: var(--system-accent); background: color-mix(in srgb, var(--system-accent) 12%, transparent); border-radius: 5px; box-shadow: 0 0 0 1px var(--system-accent); }
.breadcrumbs strong { color: var(--ink); }
.top-actions { grid-column: 3; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.search-trigger { display: flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px; color: var(--muted); border: 0; border-radius: 14px; background: transparent; font-size: 14px; line-height: 22px; cursor: pointer; transition: background 150ms var(--ease-reference); }
.search-trigger:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 5%, transparent); }
.search-trigger svg { width: 18px; }
.search-trigger kbd { flex: none; padding: 0 4px; color: var(--muted); background: var(--soft); border: 0; border-radius: 4px; font-size: 11px; line-height: 18px; white-space: nowrap; }
.button, .icon-button { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--line-strong); background: var(--panel); cursor: pointer; }
.button { gap: 7px; min-height: 40px; padding: 7px 16px; border-radius: 20px; font-size: 14px; font-weight: 550; }
.button svg { width: 15px; }
.button:hover, .icon-button:hover { background: var(--soft); }
.button.primary { color: white; border-color: var(--accent); background: var(--accent); }
.button.primary:hover { background: #3b3b37; }
.button.ghost { border-color: transparent; background: transparent; }
.button.full { width: 100%; margin-top: 4px; }
.button:disabled, .icon-button:disabled { opacity: .45; cursor: default; }
.icon-button { width: 36px; height: 36px; padding: 0; border-radius: 18px; }
.new-button { min-width: 88px; }
.danger-button { color: white; border-color: var(--danger); background: var(--danger); }
.status { position: absolute; z-index: 20; top: 68px; left: 50%; display: flex; align-items: center; gap: 12px; max-width: min(520px, 80vw); padding: 9px 12px; color: #285d3a; background: #e4f2e8; border: 1px solid #b9d9c2; border-radius: 7px; box-shadow: 0 5px 18px #00000012; font-size: 13px; transform: translateX(-50%); }
.status.error { color: #8e3030; background: #f9e5e3; border-color: #e8bcb8; }
.status button { display: grid; padding: 0; border: 0; background: none; cursor: pointer; }
.status svg { width: 14px; }
.inventory-pane, .import-workspace { min-height: 0; overflow: auto; background: var(--panel); }
.inventory-pane { position: relative; z-index: 1; flex: 1; }
.inventory-pane > .pane-heading, .inventory-pane > .node-filters, .inventory-pane > .main-tree { width: 100%; max-width: 880px; margin-right: auto; margin-left: auto; }
.pane-heading { display: flex; min-height: 132px; align-items: flex-start; justify-content: space-between; padding: 28px 30px 20px; }
.heading-copy { min-width: 0; }
.pane-heading h1 { margin: 0 0 5px; font-size: 28px; line-height: 32px; font-weight: 700; letter-spacing: -.56px; }
.current-node-menu { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 8px; cursor: pointer; }
.current-node-menu:hover { color: var(--ink); background: var(--soft); }
.current-node-menu svg { width: 18px; height: 18px; }
.heading-rename { width: min(480px, 70vw); height: 34px; margin: -1px 0 4px; padding: 0 8px; color: var(--ink); background: var(--panel); border: 1px solid var(--system-accent); border-radius: 6px; outline: 0; font-size: 28px; line-height: 32px; font-weight: 700; letter-spacing: -.56px; }
.pane-heading p, .card-heading p { margin: 0; color: var(--muted); font-size: 14px; line-height: 20px; }
.pane-heading .heading-facts { max-width: min(720px, 70vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pane-heading .heading-tags { margin-top: 6px; }
.node-filters { display: flex; min-height: 52px; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 28px 12px 34px; }
.node-filter-list { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px; }
.node-filter-actions { display: flex; flex: none; align-items: center; gap: 4px; }
.local-node-search { display: flex; height: 28px; align-items: center; gap: 0; padding: 0 4px; color: var(--ink); background: transparent; border-radius: 14px; cursor: text; transition: background 150ms var(--ease-reference); }
.local-node-search:hover, .local-node-search.active { background: var(--hover); }
.local-node-search > button { display: grid; width: 20px; height: 20px; flex: none; place-items: center; padding: 0; color: inherit; background: transparent; border: 0; border-radius: 50%; cursor: pointer; }
.local-node-search > button svg { width: 20px; height: 20px; }
.local-node-search > button:last-child svg { width: 14px; height: 14px; }
.local-node-search > div { width: 0; overflow: hidden; transition: width 200ms cubic-bezier(.755,.05,.855,.06); }
.local-node-search.active > div { width: 120px; }
.local-node-search input { width: 120px; height: 28px; padding: 0 4px; color: var(--ink); background: transparent; border: 0; outline: 0; font-size: 14px; line-height: 28px; }
.local-node-search input::placeholder { color: var(--muted); }
.add-filter, .clear-filters, .node-filter { height: 28px; border: 0; border-radius: 16px; font-size: 14px; line-height: 20px; }
.add-filter { display: flex; align-items: center; gap: 4px; padding: 0 8px 0 6px; color: var(--muted); background: transparent; cursor: pointer; }
.add-filter:hover { color: var(--ink); background: var(--hover); }
.add-filter svg { width: 20px; height: 20px; }
.clear-filters { flex: none; padding: 3px 8px; color: var(--muted); background: transparent; font-weight: 500; cursor: pointer; }
.clear-filters:hover { color: var(--ink); }
.node-filter { position: relative; display: flex; max-width: 320px; align-items: center; gap: 4px; padding: 0 7px 0 8px; color: var(--system-accent); background: color-mix(in srgb, var(--system-accent) 12%, transparent); }
.node-filter:hover { background: color-mix(in srgb, var(--system-accent) 20%, transparent); }
.node-filter strong { font-weight: 600; }
.node-filter span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-filter input, .node-filter select { position: absolute; z-index: 1; inset: 0 25px 0 0; width: calc(100% - 25px); opacity: 0; cursor: pointer; }
.node-filter input:focus { position: static; width: 120px; height: 22px; padding: 0 5px; opacity: 1; color: var(--ink); background: var(--panel); border: 0; border-radius: 4px; outline: 1px solid var(--system-accent); cursor: text; }
.node-filter:has(input:focus) > strong, .node-filter:has(input:focus) > span { display: none; }
.node-filter input:focus + button { margin-left: -2px; }
.node-filter > button { z-index: 2; display: grid; width: 20px; height: 20px; place-items: center; padding: 0; color: inherit; background: transparent; border: 0; border-radius: 50%; cursor: pointer; }
.node-filter > button:hover { background: #ffffff80; }
.node-filter > button svg { width: 11px; height: 11px; }
.filter-menu-scrim { position: fixed; z-index: 199; inset: 0; padding: 0; border: 0; background: transparent; }
.filter-property-menu { position: fixed; z-index: 200; width: 240px; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 10px 32px #00000024; animation: popup-in 120ms var(--ease-reference); }
.filter-menu-title { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 8px; color: var(--muted); font-size: 12px; font-weight: 600; }
.filter-menu-title svg { width: 18px; }
.filter-property-menu > button { display: flex; width: 100%; height: 36px; align-items: center; gap: 10px; padding: 0 8px; color: var(--ink); background: transparent; border: 0; border-radius: 7px; text-align: left; cursor: pointer; }
.filter-property-menu > button:hover { background: var(--hover); }
.filter-property-icon { display: grid; width: 20px; height: 20px; place-items: center; color: var(--muted); background: var(--soft); border-radius: 4px; font-size: 11px; font-weight: 600; }
.main-tree { padding: 6px 20px 40px; }
.main-tree.folder-contents { min-height: 220px; transition: background 120ms ease, box-shadow 120ms ease; }
.main-tree.current-drop-target { background: color-mix(in srgb, var(--system-accent) 7%, transparent); box-shadow: inset 0 0 0 2px var(--system-accent); border-radius: 10px; }
.tree-row { display: flex; align-items: center; min-height: 52px; border-radius: 8px; transition: background 150ms var(--ease-reference); }
.tree-row.content-row { padding-left: 12px; }
.tree-row:hover { background: var(--soft); }
.tree-row.selected { background: var(--selected); }
.tree-row.checked { background: var(--system-selection); }
.tree-row.dragging { opacity: .45; }
.tree-row.drop-target { background: color-mix(in srgb, var(--system-accent) 18%, var(--panel)); box-shadow: inset 0 0 0 2px var(--system-accent); }
.tree-row.drop-invalid { cursor: not-allowed; opacity: .55; }
.tree-node[draggable="true"] { user-select: none; }
.tree-row.compact { min-height: 30px; height: 30px; }
.disclosure { display: grid; flex: none; place-items: center; width: 22px; height: 26px; padding: 0; color: #8b8b84; border: 0; background: transparent; cursor: pointer; }
.disclosure svg { width: 13px; transition: transform .12s ease; }
.disclosure.open svg { transform: rotate(90deg); }
.disclosure:disabled { visibility: hidden; }
.tree-node { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; align-self: stretch; padding: 7px 12px 7px 2px; border: 0; background: transparent; text-align: left; cursor: pointer; }
.node-tile { display: grid; flex: none; place-items: center; width: 20px; height: 20px; color: var(--ink); background: var(--soft); border-radius: 4px; font-size: 11px; }
.tree-row.selected .node-tile { background: color-mix(in srgb, var(--muted) 16%, var(--soft)); }
.tree-copy { display: grid; min-width: 0; flex: 1; gap: 1px; }
.tree-copy strong { overflow: hidden; font-size: 14px; line-height: 22px; font-weight: 500; letter-spacing: -.12px; text-overflow: ellipsis; white-space: nowrap; }
.tree-copy small { overflow: hidden; color: var(--muted); font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
.row-rename { display: none; flex: none; place-items: center; width: 30px; height: 30px; margin-right: 7px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
.content-row:hover .row-rename, .content-row:focus-within .row-rename { display: grid; }
.row-rename:hover { color: var(--ink); background: var(--line); }
.row-rename svg { width: 15px; height: 15px; }
.inline-rename { width: min(420px, 100%); height: 26px; padding: 0 7px; color: var(--ink); background: var(--panel); border: 1px solid var(--system-accent); border-radius: 5px; outline: 0; font-size: 14px; font-weight: 500; }
.folder-empty { min-height: 300px; }
.row-tags { display: flex; min-width: 0; max-width: 100%; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.row-tags span { display: inline-flex; min-width: 0; max-width: 100%; align-items: center; gap: 4px; overflow: hidden; padding: 1px 6px; border-radius: 999px; background: var(--soft); color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.row-tags i { width: 6px; height: 6px; border-radius: 50%; }
.danger-text { color: var(--danger); }
.field { display: grid; gap: 7px; margin-bottom: 15px; }
.field > span { color: var(--muted); font-size: 14px; font-weight: 400; }
.field em { color: #92928b; font-style: normal; font-weight: 400; }
.field input, .field select, .field textarea { width: 100%; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 7px; outline: 0; }
.field input, .field select { height: 40px; padding: 0 16px; border-radius: 20px; }
.field textarea { border-radius: 12px; }
.field textarea { min-height: 340px; padding: 12px; resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.6; }
.field textarea.quick-add-input { min-height: 64px; resize: vertical; font-family: inherit; font-size: 16px; line-height: 24px; }
.field textarea.description-input { min-height: 76px; resize: vertical; font-family: inherit; font-size: 14px; line-height: 20px; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: #8a8a83; box-shadow: 0 0 0 2px #0000000c; }
.field input:disabled { color: #777770; background: #f0f0ec; }
.field.readonly { grid-template-columns: 1fr auto; align-items: center; padding: 7px 0; }
.field.readonly strong { font-size: 12px; font-weight: 500; }
.empty-state { display: flex; height: 100%; min-height: 260px; align-items: center; justify-content: center; flex-direction: column; padding: 40px; color: var(--muted); text-align: center; }
.empty-state > svg { width: 28px; height: 28px; margin-bottom: 12px; }
.empty-state h2 { margin: 0 0 6px; color: #55554f; font-size: 14px; }
.empty-state p { max-width: 260px; margin: 0; font-size: 12px; line-height: 1.5; }
.empty-state.compact { min-height: 360px; }
.skeleton-list { display: grid; gap: 8px; padding: 8px; }
.skeleton-list i { width: 75%; height: 22px; background: #e5e5e0; border-radius: 5px; animation: pulse 1.4s infinite alternate; }
.skeleton-list i:nth-child(2) { width: 58%; margin-left: 16px; }.skeleton-list i:nth-child(3) { width: 68%; margin-left: 16px; }.skeleton-list i:nth-child(4) { width: 50%; margin-left: 32px; }
@keyframes pulse { to { opacity: .45; } }
.search-backdrop { position: fixed; z-index: 110; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(0,0,0,.25); }
.search-dialog { display: flex; width: min(684px, 100%); height: min(706px, calc(100vh - 48px)); flex-direction: column; overflow: hidden; background: var(--panel); border-radius: 24px; box-shadow: 0 2px 28px rgba(0,0,0,.2); animation: popup-in 200ms var(--ease-reference); }
.search-input-row { display: flex; height: 55px; flex: none; align-items: center; gap: 8px; padding: 0 20px; border-bottom: 1px solid var(--line); }
.search-input-row > svg { width: 18px; color: var(--muted); }
.search-input-row input { min-width: 0; flex: 1; height: 54px; padding: 0; color: var(--ink); border: 0; outline: 0; background: transparent; font-size: 16px; line-height: 24px; font-weight: 500; }
.clear-search { display: grid; width: 28px; height: 28px; place-items: center; color: var(--muted); border: 0; border-radius: 14px; background: transparent; cursor: pointer; }
.clear-search:hover { color: var(--ink); background: color-mix(in srgb, var(--ink) 5%, transparent); }
.clear-search svg { width: 16px; }
.search-section-label { height: 26px; flex: none; padding: 5px 16px; color: var(--muted); font-size: 12px; line-height: 18px; font-weight: 500; }
.search-filters { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--line); }
.search-filters select { min-width: 0; height: 30px; flex: 1; padding: 0 10px; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 15px; font-size: 12px; }
.search-result-list { min-height: 0; flex: 1; overflow: auto; padding: 0 8px 8px; }
.search-result-row { display: flex; width: 100%; min-height: 56px; align-items: center; gap: 12px; padding: 8px 12px; border: 0; border-radius: 8px; background: transparent; text-align: left; cursor: pointer; }
.search-result-row:hover, .search-result-row.active { background: color-mix(in srgb, var(--ink) 5%, transparent); }
.search-result-icon { display: grid; width: 40px; height: 40px; flex: none; place-items: center; background: var(--soft); border-radius: 6px; }
.search-result-icon svg { width: 20px; }
.search-result-copy { display: grid; min-width: 0; gap: 2px; }
.search-result-copy strong { overflow: hidden; font-size: 14px; line-height: 22px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.search-result-copy small { overflow: hidden; color: var(--muted); font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
.search-empty { min-height: 240px; }
.search-footer { display: flex; min-height: 40px; flex: none; flex-wrap: wrap; align-items: center; gap: 4px 24px; padding: 6px 16px; color: var(--muted); border-top: 1px solid var(--line); font-size: 12px; line-height: 18px; }
.search-footer span { display: flex; flex: none; align-items: center; gap: 4px; white-space: nowrap; }
.search-footer kbd { min-width: 18px; height: 18px; flex: none; padding: 0 4px; background: var(--soft); border: 0; border-radius: 4px; font: inherit; text-align: center; white-space: nowrap; }
.import-workspace { flex: 1; padding-bottom: 40px; }
.import-grid { display: grid; grid-template-columns: minmax(320px, .8fr) minmax(400px, 1.2fr); gap: 16px; padding: 0 30px; }
.card { overflow: hidden; background: color-mix(in srgb, var(--ink) 3%, var(--panel)); border-radius: 12px; }
.import-form { display: flex; flex-direction: column; min-height: 560px; padding: 20px; }
.import-form .grow { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.import-form .grow textarea { flex: 1; }
.preview-card { position: relative; min-height: 560px; }
.card-heading { display: flex; align-items: center; justify-content: space-between; min-height: 65px; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.card-heading h2 { margin: 0 0 4px; font-size: 14px; }
.import-tree { padding: 5px 0; }
.import-row { display: flex; align-items: center; gap: 8px; min-height: 34px; font-size: 12px; }
.import-row:hover { background: var(--soft); }
.import-row svg { width: 15px; color: #71716b; }
.import-row strong { font-weight: 550; }
.import-row span { margin-left: auto; margin-right: 16px; color: var(--muted); }
.preview-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding: 14px 18px; background: var(--sidebar); border-top: 1px solid var(--line); }
.preview-footer span { color: var(--muted); font-size: 11px; }
.form-error { margin: 0 0 12px; color: var(--danger); font-size: 12px; }
.multiple-option { display: flex; align-items: flex-start; gap: 9px; margin: -3px 0 16px; color: var(--muted); font-size: 13px; line-height: 19px; cursor: pointer; }
.multiple-option input { width: 17px; height: 17px; flex: none; margin: 1px 0 0; accent-color: #363636; }
.tag-selector { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
.tag-selector.disabled { opacity: .65; }
.selected-tag-list { display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; }
.selected-tag { display: inline-flex; align-items: center; max-width: 100%; height: 24px; padding: 0 7px; border-radius: 12px; font-size: 12px; line-height: 24px; }
.selected-tag > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selected-tag button { display: grid; place-items: center; width: 20px; height: 24px; margin: 0 -5px 0 2px; padding: 0; color: inherit; background: transparent; border: 0; border-radius: 50%; cursor: pointer; opacity: .65; }
.selected-tag button:hover { opacity: 1; }
.selected-tag button svg { width: 10px; height: 10px; }
.tag-placeholder { color: var(--muted); font-size: 12px; }
.tag-option-trigger { display: flex; align-items: center; gap: 3px; height: 24px; padding: 0 7px; color: var(--muted); background: transparent; border: 0; border-radius: 6px; font-size: 12px; cursor: pointer; }
.tag-option-trigger svg { width: 13px; height: 13px; }
.tag-option-trigger:hover, .tag-option-trigger.active { color: var(--ink); background: var(--soft); }
.tag-option-popover { z-index: 200; width: 240px; overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, .14); }
.tag-option-portal { position: fixed; }
.tag-option-search { display: flex; align-items: center; gap: 7px; height: 38px; padding: 0 10px; border-bottom: 1px solid var(--line); }
.tag-option-search svg { width: 15px; height: 15px; color: var(--muted); }
.tag-option-search input { width: 100%; height: 100%; padding: 0; background: transparent; border: 0; outline: 0; }
.tag-option-list { max-height: 220px; padding: 5px; overflow-y: auto; }
.tag-option-row { position: relative; display: flex; min-height: 32px; border-radius: 6px; }
.tag-option-row:hover, .tag-option-row.selected { background: var(--soft); }
.tag-option-value, .tag-option-list .create-tag-option { display: grid; grid-template-columns: 10px minmax(0, 1fr) 16px; align-items: center; gap: 7px; width: 100%; min-height: 32px; padding: 5px 8px; text-align: left; background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
.tag-option-row:hover .tag-option-value { padding-right: 34px; }
.tag-option-value i, .bulk-tag-actions i { width: 8px; height: 8px; flex: none; border-radius: 50%; }
.tag-option-value span, .tag-option-list .create-tag-option span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-option-value b { color: var(--system-accent); font-size: 13px; text-align: center; }
.tag-option-value b svg { width: 14px; height: 14px; }
.tag-option-more { position: absolute; top: 2px; right: 3px; display: none; place-items: center; width: 28px; height: 28px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; }
.tag-option-row:hover .tag-option-more, .tag-option-more:focus-visible { display: grid; }
.tag-option-more:hover { color: var(--ink); background: var(--line); }
.tag-option-more svg { width: 16px; height: 16px; }
.tag-option-empty { padding: 18px 10px; color: var(--muted); font-size: 12px; text-align: center; }
.tag-option-list .create-tag-option { color: var(--ink); border-bottom: 1px solid var(--line); border-radius: 0; font-weight: 500; }
.tag-option-list .create-tag-option:hover { background: var(--soft); }
.tag-option-list .create-tag-option > svg { width: 14px; height: 14px; }
.tag-create-error { padding: 6px 8px; color: var(--danger); font-size: 11px; }
.tag-edit-heading { display: grid; grid-template-columns: 30px 1fr 30px; align-items: center; height: 38px; padding: 0 5px; }
.tag-edit-heading button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; }
.tag-edit-heading button:hover { color: var(--ink); background: var(--soft); }
.tag-edit-heading button svg { width: 15px; height: 15px; transform: rotate(180deg); }
.tag-edit-heading strong { font-size: 12px; font-weight: 600; text-align: center; }
.tag-option-editor .tag-option-search { margin: 0 10px 8px; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; }
.tag-color-grid { display: grid; grid-template-columns: repeat(5, 28px); justify-content: center; gap: 4px 12px; padding: 8px 14px 16px; border-bottom: 1px solid var(--line); }
.tag-color-grid button { position: relative; width: 28px; height: 28px; padding: 0; background: transparent; border: 0; border-radius: 50%; cursor: pointer; }
.tag-color-grid button i { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border-radius: 50%; transition: transform 150ms ease; }
.tag-color-grid button:hover i { transform: scale(1.17); }
.tag-color-grid button.selected { box-shadow: 0 0 0 1px var(--ink); }
.tag-delete-action { width: calc(100% - 10px); min-height: 36px; margin: 5px; padding: 0 10px; color: var(--danger); text-align: left; background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
.tag-delete-action:hover { background: var(--soft); }
.node-context-menu { position: fixed; z-index: 300; width: 222px; padding: 5px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, .16); }
.context-heading { overflow: hidden; padding: 7px 9px 5px; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.node-context-menu button { width: 100%; min-height: 32px; padding: 5px 9px; color: var(--ink); text-align: left; background: transparent; border: 0; border-radius: 6px; font-size: 13px; cursor: pointer; }
.node-context-menu button:hover { background: var(--soft); }
.node-context-menu button.danger { color: var(--danger); }
.node-context-menu button:disabled { opacity: .4; cursor: default; }
.context-divider { height: 1px; margin: 4px 5px; background: var(--line); }
.inline-tag-create { display: flex; gap: 7px; margin-bottom: 12px; }
.inline-tag-create input { min-width: 0; height: 38px; flex: 1; padding: 0 12px; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 8px; outline: 0; }
.inline-tag-create .button { min-height: 38px; }
.context-tag-list { display: grid; max-height: 280px; gap: 3px; overflow: auto; }
.context-tag-list button { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 34px; padding: 5px 9px; text-align: left; background: transparent; border: 0; border-radius: 7px; cursor: pointer; }
.context-tag-list button:hover, .context-tag-list button.add, .context-tag-list button.all { background: var(--soft); }
.context-tag-list button.remove { opacity: .55; text-decoration: line-through; }
.context-tag-list i { width: 8px; height: 8px; border-radius: 50%; }
.context-tag-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.context-tag-list small { color: var(--muted); font-size: 11px; }
.bulk-tag-actions { display: grid; max-height: 220px; gap: 5px; overflow: auto; }
.bulk-tag-actions > label { display: grid; grid-template-columns: 10px 1fr 112px; align-items: center; gap: 7px; min-height: 36px; padding: 3px 7px; background: var(--soft); border-radius: 7px; font-size: 12px; }
.bulk-tag-actions select { height: 30px; padding: 0 8px; }
.metadata-workspace { min-height: 0; flex: 1; overflow: auto; background: var(--panel); }
.metadata-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 30px 40px; }
.metadata-grid.single { grid-template-columns: minmax(0, 680px); }
.metadata-card { min-height: 360px; }
.metadata-create { display: flex; gap: 8px; padding: 14px; border-bottom: 1px solid var(--line); }
.metadata-create input, .metadata-editor input:not(.metadata-color) { min-width: 0; height: 38px; padding: 0 12px; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 7px; }
.metadata-create input { flex: 1; }
.metadata-create .button { min-height: 38px; }
.metadata-list { display: grid; gap: 1px; padding: 8px; }
.metadata-editor { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto auto; align-items: center; gap: 7px; padding: 8px; border-radius: 8px; }
.metadata-editor:hover { background: var(--soft); }
.metadata-editor > div { display: grid; gap: 5px; }
.metadata-editor input.metadata-description { height: 30px; color: var(--muted); font-size: 11px; }
.metadata-editor.compact { grid-template-columns: 32px minmax(0, 1fr) auto auto; }
.preset-color-picker { position: relative; width: 30px; height: 30px; }
.preset-color-picker summary { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 7px; cursor: pointer; list-style: none; }
.preset-color-picker summary::-webkit-details-marker { display: none; }
.preset-color-picker summary:hover, .preset-color-picker[open] summary { background: var(--line); }
.preset-color-picker summary > span { width: 20px; height: 20px; border-radius: 50%; }
.preset-color-popover { position: absolute; z-index: 20; top: 36px; left: 0; display: grid; grid-template-columns: repeat(5, 30px); gap: 4px; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14); }
.preset-color-popover button { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 7px; background: transparent; cursor: pointer; }
.preset-color-popover button:hover, .preset-color-popover button.selected { background: var(--soft); }
.preset-color-popover button.selected { box-shadow: inset 0 0 0 1px var(--line-strong); }
.preset-color-popover button span { width: 20px; height: 20px; border-radius: 50%; }
.metadata-editor .button { min-height: 32px; padding: 4px 9px; font-size: 12px; }
.export-workspace { min-height: 0; flex: 1; overflow: auto; background: var(--panel); }
.export-grid { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 16px; padding: 0 30px 40px; }
.export-controls { align-self: start; padding: 18px; }
.export-choice { display: grid; grid-template-columns: 18px 1fr; align-items: start; gap: 8px; padding: 9px 8px; border-radius: 8px; cursor: pointer; }
.export-choice:hover { background: var(--soft); }
.export-choice input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--system-accent); }
.export-choice span { display: grid; gap: 2px; }
.export-choice strong { font-size: 13px; font-weight: 550; }
.export-choice small, .export-summary span { color: var(--muted); font-size: 11px; line-height: 16px; }
.export-summary { display: grid; gap: 2px; margin: 8px 0 18px; padding: 12px; background: var(--soft); border-radius: 8px; }
.export-summary strong { font-size: 13px; }
.export-note { margin: 0 0 18px; color: var(--muted); font-size: 12px; line-height: 18px; }
.export-preview { min-width: 0; height: min(680px, calc(100vh - 145px)); }
.export-preview pre { height: calc(100% - 64px); margin: 0; padding: 16px; overflow: auto; color: var(--ink); background: var(--soft); font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }
.metadata-empty { padding: 28px; color: var(--muted); font-size: 12px; text-align: center; }
.name-preview { display: grid; gap: 7px; margin: 2px 0 14px; }
.name-preview > span { color: var(--muted); font-size: 14px; }
.name-preview > div { display: grid; max-height: 160px; gap: 4px; padding: 10px 12px; overflow: auto; background: var(--soft); border-radius: 9px; }
.name-preview code { font-size: 12px; }
.name-preview small { color: var(--muted); font-size: 11px; }
.identity-warning { margin: 4px 0 10px; padding: 12px; color: var(--muted); background: var(--soft); border-radius: 9px; font-size: 12px; line-height: 18px; }
.identity-warning strong { color: var(--ink); font-family: "SFMono-Regular", Consolas, monospace; letter-spacing: .06em; }
.move-tree { max-height: 280px; overflow: auto; padding: 4px; border: 1px solid var(--line); border-radius: 10px; }
.move-tree-row { display: flex; min-width: 0; align-items: center; height: 38px; border-radius: 6px; }
.move-tree-row:hover, .move-tree-row.selected { background: var(--soft); }
.move-tree-node { display: flex; min-width: 0; flex: 1; align-items: center; align-self: stretch; gap: 8px; padding: 0 8px 0 2px; border: 0; background: transparent; cursor: pointer; }
.move-tree-node > span:first-child { min-width: 0; flex: 1; overflow: hidden; font-size: 14px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.radio-dot { width: 18px; height: 18px; flex: none; border: 2px solid var(--line-strong); border-radius: 50%; }
.radio-dot.selected { border: 5px solid var(--ink); }
.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: #22221e66; backdrop-filter: blur(2px); }
.modal { width: min(480px, 100%); overflow: hidden; background: var(--panel); border-radius: 24px; box-shadow: 0 2px 28px rgba(0,0,0,.2); animation: popup-in 200ms var(--ease-reference); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 17px 18px 13px 22px; }
.modal-header h2 { margin: 0; font-size: 22px; line-height: 28px; font-weight: 700; letter-spacing: -.48px; }
.modal-header .icon-button { border-color: transparent; }
.modal-body { padding: 8px 22px 12px; }
.modal-intro { margin: 0 0 20px; color: var(--muted); font-size: 13px; line-height: 1.5; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 13px 18px; background: var(--panel); border-top: 1px solid var(--line); }
@keyframes popup-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } }
.label-sheet-backdrop { padding: 28px; }
.label-sheet-dialog { display: flex; width: min(1120px, 100%); height: min(860px, calc(100vh - 56px)); flex-direction: column; overflow: hidden; background: var(--panel); border-radius: 24px; box-shadow: 0 2px 28px rgba(0,0,0,.2); animation: popup-in 200ms var(--ease-reference); }
.label-sheet-dialog .modal-header { flex: none; border-bottom: 1px solid var(--line); }
.label-sheet-dialog .modal-header p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
.label-sheet-content { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: 0; flex: 1; }
.label-sheet-controls { padding: 22px; overflow: auto; border-right: 1px solid var(--line); }
.template-detail { margin: -8px 2px 22px; color: var(--muted); font-size: 12px; line-height: 18px; }
.start-position-summary { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; margin: 4px 0 14px; padding: 11px 12px; background: var(--soft); border-radius: 9px; }
.start-position-summary span { color: var(--muted); font-size: 11px; }
.start-position-summary strong { grid-row: 1 / 3; grid-column: 2; align-self: center; font-size: 13px; }
.start-position-summary small { color: var(--ink); font-size: 11px; }
.custom-template-editor { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: -4px 0 20px; padding: 12px; background: var(--soft); border-radius: 10px; }
.custom-template-editor label { display: grid; gap: 4px; color: var(--muted); font-size: 10px; line-height: 14px; }
.custom-template-editor input { width: 100%; height: 32px; padding: 0 8px; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: 6px; font-size: 12px; }
.print-note { display: grid; gap: 5px; margin-top: 26px; padding: 13px; background: var(--soft); border-radius: 9px; font-size: 12px; line-height: 18px; }
.print-note strong { font-size: 13px; }
.print-note span { color: var(--muted); }
.sheet-preview-area { min-height: 0; padding: 28px; overflow: auto; background: #e7e7e3; }
.sheet-preview { width: min(650px, 100%); margin: 0 auto 28px; }
.sheet-preview figcaption { margin-top: 8px; color: #686862; font-size: 11px; text-align: center; }
.sheet-page { position: relative; width: 100%; overflow: hidden; background: white; box-shadow: 0 2px 12px #0002; }
.invalid-sheet-preview { display: grid; min-height: 300px; place-items: center; color: #686862; background: #fff; border: 1px dashed #bdbdb7; font-size: 13px; }
.sheet-label { position: absolute; display: flex; align-items: center; gap: 3%; padding: 0; overflow: hidden; color: #171717; background: white; border: 0; outline: 1px dashed #d8d8d4; font: inherit; text-align: left; }
.sheet-label.selectable { cursor: pointer; transition: background 100ms ease, box-shadow 100ms ease; }
.sheet-label.selectable:hover { z-index: 2; background: #f2f6ff; box-shadow: inset 0 0 0 2px #79a5ff; outline: 0; }
.sheet-label.used-position { background: repeating-linear-gradient(135deg, #ededeb, #ededeb 4px, #e3e3e0 4px, #e3e3e0 8px); }
.sheet-label.start-position { z-index: 1; box-shadow: inset 0 0 0 2px var(--system-accent); outline: 0; }
.sheet-label:focus-visible { z-index: 3; outline: 3px solid var(--system-accent); outline-offset: 1px; }
.slot-number { display: grid; width: 100%; height: 100%; place-items: center; color: #aaa; font-size: clamp(4px, .65vw, 7px); }
.used-position .slot-number { color: #777; }
.sheet-label > img { width: 28%; height: auto; margin-left: 4%; image-rendering: pixelated; }
.sheet-label > span { display: grid; min-width: 0; flex: 1; align-content: center; gap: 2px; padding-right: 4%; }
.sheet-label > span.slot-number { width: 100%; height: 100%; place-items: center; padding: 0; }
.sheet-label strong, .sheet-label small, .sheet-label code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sheet-label strong { font-size: clamp(5px, .9vw, 9px); }
.sheet-label small { color: #666; font-size: clamp(3px, .55vw, 6px); }
.sheet-label code { margin-top: 2px; color: #171717; font-size: clamp(4px, .7vw, 7px); font-weight: 700; letter-spacing: .04em; }
@media (prefers-color-scheme: dark) {
:root {
color: #f1f1ed;
background: #171717;
--canvas: #171717;
--panel: #171717;
--sidebar: #20201f;
--line: #343432;
--line-strong: #484845;
--muted: #aaaaa4;
--soft: #292927;
--hover: #292927;
--selected: #292927;
--ink: #f1f1ed;
--accent: #363636;
}
.search-trigger kbd { background: #292927; }
.status { color: #bde4c7; background: #193523; border-color: #315c3d; }
.status.error { color: #f0b4b0; background: #411f1d; border-color: #71332f; }
.modal-backdrop { background: #00000099; }
.modal-footer, .preview-footer { background: #20201f; }
.field input:disabled { color: #aaaaa4; background: #292927; }
}
@media (max-width: 980px) {
.app-shell { grid-template-columns: 56px minmax(0, 1fr); }
.sidebar { padding-right: 6px; padding-left: 6px; }
.brand { justify-content: center; padding: 0; }
.nav-list { margin-right: 0; margin-left: 0; }
.nav-item { justify-content: center; gap: 0; padding: 0; }
.nav-item span { display: none; }
.nav-item svg { width: 18px; height: 18px; }
.search-trigger span, .search-trigger kbd { display: none; }
.import-grid { grid-template-columns: 1fr; }
.metadata-grid { grid-template-columns: 1fr; }
.export-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
body { overflow: auto; }
.app-shell { display: block; height: auto; min-height: 100vh; }
.sidebar { position: fixed; z-index: 40; right: 0; bottom: 0; left: 0; display: block; width: 100%; height: calc(60px + env(safe-area-inset-bottom)); padding: 0 8px env(safe-area-inset-bottom); background: color-mix(in srgb, var(--panel) 96%, transparent); border-top: 1px solid var(--line); backdrop-filter: blur(16px); }
.sidebar .brand { display: none; }
.nav-list { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; height: 60px; margin: 0; }
.nav-item { height: 56px; flex-direction: column; justify-content: center; gap: 3px; padding: 4px 2px 2px; border-radius: 8px; color: var(--muted); font-size: 10px; line-height: 13px; }
.nav-item span { display: block; }
.nav-item svg { width: 20px; height: 20px; color: currentColor; }
.nav-item.active { color: var(--ink); background: var(--soft); }
.workspace { min-height: 100vh; padding-bottom: calc(60px + env(safe-area-inset-bottom)); overflow: visible; }
.topbar { position: sticky; z-index: 10; top: 0; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: 52px 40px; gap: 0 8px; height: 92px; padding: 0 12px; border-bottom: 1px solid var(--line); }
.history-actions { position: static; grid-column: 1; grid-row: 1; }
.breadcrumbs { display: flex; grid-column: 1 / -1; grid-row: 2; width: 100%; padding: 0 2px 7px; }
.top-actions { grid-column: 3; grid-row: 1; width: auto; }
.top-actions > .icon-button { display: none; }
.inventory-pane { min-height: calc(100vh - 152px); border: 0; }
.pane-heading { min-height: 122px; padding: 22px 18px 16px; }
.main-tree { padding: 5px 8px 30px; }
.import-grid { padding: 0 14px; }
.metadata-grid { padding: 0 14px 30px; }
.export-grid { padding: 0 14px 30px; }
.export-preview { height: 60vh; }
.import-workspace .pane-heading { padding-left: 16px; }
.search-backdrop { align-items: end; padding: 0; }
.search-dialog { width: 100%; height: 80vh; max-height: 80vh; border-radius: 24px 24px 0 0; }
.search-footer { display: none; }
.field input, .field select { height: 44px; border-radius: 22px; }
.modal-backdrop { align-items: end; padding: 0; }
.modal { width: 100%; max-height: 90vh; border-radius: 24px 24px 0 0; }
.modal-body { overflow: auto; }
.label-sheet-backdrop { align-items: end; padding: 0; }
.label-sheet-dialog { width: 100%; height: 94vh; border-radius: 24px 24px 0 0; }
.label-sheet-content { display: block; overflow: auto; }
.label-sheet-controls { overflow: visible; border-right: 0; border-bottom: 1px solid var(--line); }
.sheet-preview-area { overflow: visible; padding: 16px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8080",
"/n": "http://127.0.0.1:8080"
}
}
});