Compare commits

...
Sign in to create a new pull request.

88 commits

Author SHA1 Message Date
Yaro Kasear
d6bb3d8780 All good. Now for more fun. 2026-01-21 13:17:43 -06:00
Yaro Kasear
ea1f43dcd3 Massive refactor. 2026-01-21 10:49:05 -06:00
Yaro Kasear
2561127221 Tweaking path smoothing and simplification. 2026-01-14 13:26:13 -06:00
Yaro Kasear
059d5ee9ba Make minStep jitter handling more dynamic. 2026-01-14 11:37:00 -06:00
Yaro Kasear
27a29d9c66 Limit the "edges" of the grid. 2026-01-14 11:20:01 -06:00
Yaro Kasear
41fafae501 Add further validation to encodeStates. 2026-01-14 11:12:38 -06:00
Yaro Kasear
82c3ea2b90 Various bug fixes. 2026-01-12 16:35:20 -06:00
Yaro Kasear
cc32b2214c Delta differential coding 2026-01-12 15:12:11 -06:00
Yaro Kasear
5a2f480ef7 Selection! 2026-01-12 10:59:26 -06:00
Yaro Kasear
4e2cd2b0e5 Blah 2026-01-09 16:23:09 -06:00
Yaro Kasear
760fe603c9 Minor fix in the encoder for quantizing state changes. 2026-01-09 14:26:12 -06:00
Yaro Kasear
296f29db0c New encoding format!!!! 2026-01-09 13:48:58 -06:00
Yaro Kasear
1ee3a05ab9 Apply a needed fix to notes. 2026-01-09 09:35:43 -06:00
Yaro Kasear
62629c6a3d Fixing encoding a bit for space. 2026-01-09 08:45:38 -06:00
Yaro Kasear
2993bc400a Changing test image. 2026-01-08 16:06:13 -06:00
Yaro Kasear
6d711474df Final fixes to CSS. 2026-01-08 11:58:53 -06:00
Yaro Kasear
c51ef38d99 Fix viewer translation. 2026-01-08 09:51:47 -06:00
Yaro Kasear
34d4a28622 Resizing viewer. 2026-01-08 09:36:09 -06:00
Yaro Kasear
6ab8ce4069 Viewer is working, in theory! 2026-01-07 10:03:17 -06:00
Yaro Kasear
e4b59b124e Catmull-Rom smoothing! 2026-01-07 09:36:35 -06:00
Yaro Kasear
63f3ad1394 Some fixes. 2026-01-06 15:12:40 -06:00
Yaro Kasear
ff2734fff6 More adjustments to js and HTML. 2026-01-06 15:04:02 -06:00
Yaro Kasear
585c4abb25 Code refactor to start working on viewer. 2026-01-06 14:39:26 -06:00
Yaro Kasear
429e993009 Line simplification!!! 2026-01-06 10:01:25 -06:00
Yaro Kasear
a1cf260072 More sizing improvements. 2025-12-31 13:56:06 -06:00
Yaro Kasear
86d24ccb43 More improvements on style of draw widget. 2025-12-31 13:45:06 -06:00
Yaro Kasear
3864f27cc6 More improvement for toolbar. 2025-12-31 13:06:41 -06:00
Yaro Kasear
a05218b985 Various improvements to make things happier. 2025-12-31 09:19:11 -06:00
Yaro Kasear
6f175c103e Fix dropdown behavior. 2025-12-31 08:48:35 -06:00
Yaro Kasear
d2061f5c1c More UI improvements. 2025-12-19 15:55:37 -06:00
Yaro Kasear
4c98de2eef Trying to make UX improvements. 2025-12-19 10:52:47 -06:00
Yaro Kasear
03804cc476 Fix undo/redo not firing when in a text box. 2025-12-18 11:22:57 -06:00
Yaro Kasear
65beb3509c Adding some opacity and stroke width control. 2025-12-18 09:59:01 -06:00
ce562d34de Multiple widget support? 2025-12-17 14:08:38 -06:00
5dfc2691e9 Fix to finalizing shape behavior. 2025-12-17 12:29:58 -06:00
b4c448d572 Fix naming. 2025-12-17 12:01:12 -06:00
24b74f78c0 Pen tool added. 2025-12-17 11:40:26 -06:00
c5cc368ef9 Move the dot outside the grid. 2025-12-17 10:36:42 -06:00
e8d9d1a330 Add undo/redo. 2025-12-17 09:59:32 -06:00
Yaro Kasear
641ae1470d Opacity added. 2025-12-16 15:22:23 -06:00
Yaro Kasear
802c3cd028 Fix sanitizeShapes. 2025-12-16 10:54:39 -06:00
Yaro Kasear
9d22c55aba Some more improvements. 2025-12-16 10:48:21 -06:00
Yaro Kasear
8d5ddca229 More work on drawing widget. 2025-12-15 14:25:37 -06:00
Yaro Kasear
624ebf09d3 Getting more resize and reset behavior. 2025-12-12 14:30:54 -06:00
Yaro Kasear
1926a3930d REmoving snapSizeToGrid 2025-12-12 12:13:25 -06:00
Yaro Kasear
0b85715c1e Better grid resizing behavior. 2025-12-12 12:10:14 -06:00
Yaro Kasear
3f4aee73a3 Add grid scaling. 2025-12-12 11:21:18 -06:00
Yaro Kasear
0992cf97eb More minor tweaks. 2025-12-12 10:17:38 -06:00
Yaro Kasear
47942cc6b6 Fixing more annoyign bugs. 2025-12-12 10:03:43 -06:00
Yaro Kasear
4fe3dfb8b4 Bug fixes! 2025-12-12 09:40:05 -06:00
Yaro Kasear
8abf9bdcdf Fix coordinate printing. 2025-12-09 14:47:52 -06:00
Yaro Kasear
0fb1991b5a Remove dot in no-grid mode. 2025-12-09 11:31:47 -06:00
Yaro Kasear
292ca0798c Fix some pointer behavior. 2025-12-09 11:26:39 -06:00
Yaro Kasear
9ddbacb4de Update coords box to behave based on grid mode. 2025-12-09 11:19:16 -06:00
Yaro Kasear
5cc47c4a81 More grid support. 2025-12-09 11:06:57 -06:00
Yaro Kasear
87ec637ac1 Adding grid settings buttons. 2025-12-08 16:32:53 -06:00
Yaro Kasear
fc95c87e84 More features added. 2025-12-05 09:57:54 -06:00
Yaro Kasear
d207f1da2c More features added to our draw widget. 2025-12-05 09:29:50 -06:00
Yaro Kasear
55f18b1cbe Hey, I added lines! 2025-12-04 16:15:58 -06:00
Yaro Kasear
285db679d9 Some nice refinements here. 2025-12-04 15:31:42 -06:00
Yaro Kasear
5b8f14c99b Experimental drawing widget? 2025-12-04 14:06:14 -06:00
Yaro Kasear
7d4b76d19f Latest updates. 2025-12-02 09:36:33 -06:00
Yaro Kasear
d1f00cd9d5 Image handling fixed properly. 2025-11-18 14:14:54 -06:00
Yaro Kasear
d151d68ce9 Adding image functionality. 2025-11-17 10:05:34 -06:00
Yaro Kasear
24e49341e8 All forms using form-floating nicely. 2025-11-12 09:44:48 -06:00
Yaro Kasear
c3ad0fba84 The experiment with floating labels is a success! 2025-11-12 09:09:57 -06:00
Yaro Kasear
d0551d52a1 Nice work on the user entry. 2025-11-05 11:36:05 -06:00
Yaro Kasear
4ef4d5e23f I lost track of all these changes. Congratulations me. 2025-10-31 16:31:23 -05:00
Yaro Kasear
8481a40553 Summary screen and error screens. 2025-10-29 15:51:46 -05:00
Yaro Kasear
dc3482f887 And that is that! Target number support is in. 2025-10-28 08:18:35 -05:00
Yaro Kasear
2845d340da Lots and lots of logic. 2025-10-27 15:57:07 -05:00
Yaro Kasear
4c8a8d4ac7 Custom combobox for device types. 2025-10-27 13:23:29 -05:00
Yaro Kasear
c20d085ab5 Cleanup of removed condition column in favor of new status column. 2025-10-24 11:25:50 -05:00
Yaro Kasear
acefd96958 More condition work. 2025-10-24 11:05:57 -05:00
Yaro Kasear
f9d950c425 Added status table. 2025-10-24 10:44:36 -05:00
Yaro Kasear
db287fb8ac Remember delete functionality? Me neither. 2025-10-23 15:46:52 -05:00
Yaro Kasear
11998b6b31 Problem report expanded. 2025-10-23 14:51:19 -05:00
Yaro Kasear
f249a935d5 SMall bug fix plus initial addition to "problem" records. 2025-10-23 10:54:44 -05:00
Yaro Kasear
46b3e2600f Minor fix to failing updates. 2025-10-22 15:55:45 -05:00
Yaro Kasear
3a2a8a06d9 Bug fix, case-insensitivity now works in dropdown filter. 2025-10-22 14:10:40 -05:00
Yaro Kasear
c31da91716 Filters completed. 2025-10-22 13:42:19 -05:00
Yaro Kasear
51520da5af Add filtering for dropdowns. 2025-10-22 13:31:13 -05:00
Yaro Kasear
5234cbdd61 SMall tweaks to the new dropdown. 2025-10-22 12:30:57 -05:00
Yaro Kasear
5718deee6b New dropdown widget! 2025-10-22 11:23:43 -05:00
Yaro Kasear
43b3df9938 Starting work on adding new dropdown selector. 2025-10-21 15:46:30 -05:00
Yaro Kasear
38bae34247 Refactoring MarkDown behavior. 2025-10-21 11:33:11 -05:00
Yaro Kasear
6357e5794f More markdown enhancements. 2025-10-21 10:19:26 -05:00
Yaro Kasear
8d26d5b084 Improving listing layout. 2025-10-21 09:14:05 -05:00
56 changed files with 4736 additions and 452 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@
inventory/static/uploads/* inventory/static/uploads/*
!inventory/static/uploads/.gitkeep !inventory/static/uploads/.gitkeep
.venv/ .venv/
.vscode/
.env .env
*.db* *.db*
*.db-journal *.db-journal

View file

@ -60,11 +60,8 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
DELETE /api/<model>/delete?id=123[&hard=1] DELETE /api/<model>/delete?id=123[&hard=1]
""" """
model_name = model.__name__.lower() model_name = model.__name__.lower()
# bikeshed if you want pluralization; this is the least-annoying default
collection = (base_prefix or model_name).lower()
plural = collection if collection.endswith('s') else f"{collection}s"
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}") bp = Blueprint(model_name, __name__, url_prefix=f"/api/{model_name}")
@bp.errorhandler(Exception) @bp.errorhandler(Exception)
def _handle_any(e: Exception): def _handle_any(e: Exception):
@ -105,7 +102,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
obj = service.create(payload) obj = service.create(payload)
resp = jsonify(obj.as_dict()) resp = jsonify(obj.as_dict())
resp.status_code = 201 resp.status_code = 201
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False) resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False)
return resp return resp
except Exception as e: except Exception as e:
return _json_error(e) return _json_error(e)

View file

@ -415,6 +415,8 @@ class CRUDService(Generic[T]):
opt = opt.load_only(*cols) opt = opt.load_only(*cols)
query = query.options(opt) query = query.options(opt)
# inside CRUDService._apply_firsthop_strategies
# ...
# NEW: if a first-hop to-one relationships target table is present in filter expressions, # NEW: if a first-hop to-one relationships target table is present in filter expressions,
# make sure we actually JOIN it (outer) so filters dont create a cartesian product. # make sure we actually JOIN it (outer) so filters dont create a cartesian product.
if plan.filter_tables: if plan.filter_tables:
@ -422,14 +424,19 @@ class CRUDService(Generic[T]):
for rel in mapper.relationships: for rel in mapper.relationships:
if rel.uselist: if rel.uselist:
continue # only first-hop to-one here continue # only first-hop to-one here
target_tbl = getattr(rel.mapper.class_, "__table__", None) target_cls = rel.mapper.class_
target_tbl = getattr(target_cls, "__table__", None)
if target_tbl is None: if target_tbl is None:
continue continue
if target_tbl in plan.filter_tables: if target_tbl in plan.filter_tables:
if rel.key in joined_rel_keys: if rel.key in joined_rel_keys:
continue # already joined via join_paths continue # already joined via join_paths
query = query.join(getattr(root_alias, rel.key), isouter=True)
# alias when joining same-entity relationships (User->User supervisor)
ta = aliased(target_cls) if target_cls is self.model else target_cls
query = query.join(getattr(root_alias, rel.key).of_type(ta), isouter=True)
joined_rel_keys.add(rel.key) joined_rel_keys.add(rel.key)
if log.isEnabledFor(logging.DEBUG): if log.isEnabledFor(logging.DEBUG):
info = [] info = []
for base_alias, rel_attr, target_alias in plan.join_paths: for base_alias, rel_attr, target_alias in plan.join_paths:

View file

@ -11,6 +11,8 @@ from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import crudkit
_ALLOWED_ATTRS = { _ALLOWED_ATTRS = {
"class", "placeholder", "autocomplete", "inputmode", "pattern", "class", "placeholder", "autocomplete", "inputmode", "pattern",
"min", "max", "step", "maxlength", "minlength", "min", "max", "step", "maxlength", "minlength",
@ -107,6 +109,53 @@ def register_template_globals(app=None):
app.add_template_global(fn, name) app.add_template_global(fn, name)
installed.add(name) installed.add(name)
def _fields_for_label_params(label_spec, related_model):
"""
Build a 'fields' list suitable for CRUDService.list() so labels render
without triggering lazy loads. Always includes 'id'.
"""
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
fields = set(["id"])
for c in simple_cols:
fields.add(c)
for rel_name, col_name in rel_paths:
if col_name == "__all__":
# just ensure relationship object is present; ask for rel.id
fields.add(f"{rel_name}.id")
else:
fields.add(f"{rel_name}.{col_name}")
return list(fields)
def _fk_options_via_service(related_model, label_spec, *, options_params: dict | None = None):
svc = crudkit.crud.get_service(related_model)
# default to unlimited results for dropdowns
params = {"limit": 0}
if options_params:
params.update(options_params) # caller can override limit if needed
# ensure fields needed to render the label are present (avoid lazy loads)
fields = _fields_for_label_params(label_spec, related_model)
if fields:
existing = params.get("fields")
if isinstance(existing, str):
existing = [s.strip() for s in existing.split(",") if s.strip()]
if isinstance(existing, (list, tuple)):
params["fields"] = list(dict.fromkeys(list(existing) + fields))
else:
params["fields"] = fields
# only set a default sort if caller didnt supply one
if "sort" not in params:
simple_cols, _ = _extract_label_requirements(label_spec, related_model)
params["sort"] = (simple_cols[0] if simple_cols else "id")
rows = svc.list(params)
return [
{"value": str(r.id), "label": _label_from_obj(r, label_spec)}
for r in rows
]
def expand_projection(model_cls, fields): def expand_projection(model_cls, fields):
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {} req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
out = set(fields) out = set(fields)
@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
if "label_deps" in spec: if "label_deps" in spec:
field["label_deps"] = spec["label_deps"] field["label_deps"] = spec["label_deps"]
opts_params = spec.get("options_params") or spec.get("options_filter") or spec.get("options_where")
if rel_prop: if rel_prop:
if field["type"] is None: if field["type"] is None:
field["type"] = "select" field["type"] = "select"
if field["type"] == "select" and field.get("options") is None and session is not None: if field["type"] == "select" and field.get("options") is None:
related_model = rel_prop.mapper.class_ related_model = rel_prop.mapper.class_
label_spec = ( label_spec = (
spec.get("label_spec") spec.get("label_spec")
@ -658,7 +709,11 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
or getattr(related_model, "__crud_label__", None) or getattr(related_model, "__crud_label__", None)
or "id" or "id"
) )
field["options"] = _fk_options(session, related_model, label_spec) field["options"] = _fk_options_via_service(
related_model,
label_spec,
options_params=opts_params
)
return field return field
col = mapper.columns.get(name) col = mapper.columns.get(name)

View file

@ -1,4 +1,4 @@
{# show label unless hidden/custom #} <!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{% if field_type != 'hidden' and field_label %} {% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" <label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %} {% if label_attrs %}{% for k,v in label_attrs.items() %}

View file

@ -1,5 +1,7 @@
<!-- FORM: {{ model_name|lower }} -->
<form method="POST" id="{{ model_name|lower }}_form"> <form method="POST" id="{{ model_name|lower }}_form">
{% macro render_row(row) %} {% macro render_row(row) %}
<!-- ROW: {{ row['name'] }} -->
{% if row.fields or row.children or row.legend %} {% if row.fields or row.children or row.legend %}
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %} {% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
<fieldset <fieldset

View file

@ -2,12 +2,13 @@ from __future__ import annotations
import os, logging, sys import os, logging, sys
from flask import Flask from flask import Flask, render_template, request, current_app
from jinja_markdown import MarkdownExtension from jinja_markdown import MarkdownExtension
from pathlib import Path from pathlib import Path
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.pool import Pool from sqlalchemy.pool import Pool
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.profiler import ProfilerMiddleware from werkzeug.middleware.profiler import ProfilerMiddleware
import crudkit import crudkit
@ -17,11 +18,13 @@ from crudkit.integrations.flask import init_app
from .debug_pretty import init_pretty from .debug_pretty import init_pretty
from .routes.entry import init_entry_routes from .routes.entry import init_entry_routes
from .routes.image import init_image_routes
from .routes.index import init_index_routes from .routes.index import init_index_routes
from .routes.listing import init_listing_routes from .routes.listing import init_listing_routes
from .routes.search import init_search_routes from .routes.search import init_search_routes
from .routes.settings import init_settings_routes from .routes.settings import init_settings_routes
from .routes.reports import init_reports_routes from .routes.reports import init_reports_routes
from .routes.testing import init_testing_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask: def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__) app = Flask(__name__)
@ -42,6 +45,27 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
except Exception as e: except Exception as e:
return {"error": str(e)}, 500 return {"error": str(e)}, 500
@app.errorhandler(HTTPException)
def handle_http(e: HTTPException):
code = e.code
if request.accept_mimetypes.best == 'application/json':
return {
"type": "about:blank",
"title": e.name,
"status": code,
"detail": e.description
}, code
return render_template('errors/default.html', code=code, name=e.name, description=e.description), code
@app.errorhandler(Exception)
def handle_uncaught(e: Exception):
current_app.logger.exception("Unhandled exception")
if request.accept_mimetypes.best == 'application/json':
return {"title": "Internal Server Error", "status": 500}, 500
return render_template("errors/500.html"), 500
crudkit.init_crud(app) crudkit.init_crud(app)
print(f"Effective DB URL: {str(runtime.engine.url)}") print(f"Effective DB URL: {str(runtime.engine.url)}")
@ -69,17 +93,20 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
_models.Inventory, _models.Inventory,
_models.RoomFunction, _models.RoomFunction,
_models.Room, _models.Room,
_models.Status,
_models.User, _models.User,
_models.WorkLog, _models.WorkLog,
_models.WorkNote, _models.WorkNote,
]) ])
init_entry_routes(app) init_entry_routes(app)
init_image_routes(app)
init_index_routes(app) init_index_routes(app)
init_listing_routes(app) init_listing_routes(app)
init_search_routes(app) init_search_routes(app)
init_settings_routes(app) init_settings_routes(app)
init_reports_routes(app) init_reports_routes(app)
init_testing_routes(app)
@app.teardown_appcontext @app.teardown_appcontext
def _remove_session(_exc): def _remove_session(_exc):

View file

@ -12,11 +12,12 @@ from .image import Image
from .inventory import Inventory from .inventory import Inventory
from .room_function import RoomFunction from .room_function import RoomFunction
from .room import Room from .room import Room
from .status import Status
from .user import User from .user import User
from .work_log import WorkLog from .work_log import WorkLog
from .work_note import WorkNote from .work_note import WorkNote
__all__ = [ __all__ = [
"Area", "Brand", "DeviceType", "Image", "Inventory", "Area", "Brand", "DeviceType", "Image", "Inventory",
"RoomFunction", "Room", "User", "WorkLog", "WorkNote", "RoomFunction", "Room", "Status", "User", "WorkLog", "WorkNote",
] ]

View file

@ -1,6 +1,6 @@
from typing import List, Optional from typing import List, Optional
from sqlalchemy import Boolean, Unicode from sqlalchemy import Boolean, Integer, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql from sqlalchemy.sql import expression as sql
@ -10,6 +10,7 @@ class DeviceType(Base, CRUDMixin):
__tablename__ = 'item' __tablename__ = 'item'
description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) description: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True)
target: Mapped[int] = mapped_column(Integer, nullable=True, default=0)
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type') inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='device_type')

View file

@ -1,6 +1,6 @@
from typing import List, Optional from typing import List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, case, cast, func from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, UnicodeText, case, cast, func
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.sql import expression as sql from sqlalchemy.sql import expression as sql
@ -17,9 +17,11 @@ class Inventory(Base, CRUDMixin):
name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True) name: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True) serial: Mapped[Optional[str]] = mapped_column(Unicode(255), index=True)
condition: Mapped[str] = mapped_column(Unicode(255)) condition: Mapped[Optional['Status']] = relationship('Status', back_populates='inventory')
condition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('status.id'), nullable=True, index=True)
model: Mapped[Optional[str]] = mapped_column(Unicode(255)) model: Mapped[Optional[str]] = mapped_column(Unicode(255))
notes: Mapped[Optional[str]] = mapped_column(Unicode(255)) notes: Mapped[Optional[str]] = mapped_column(UnicodeText)
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false()) shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
timestamp: Mapped[DateTime] = mapped_column(DateTime, default=func.now(), nullable=False) timestamp: Mapped[DateTime] = mapped_column(DateTime, default=func.now(), nullable=False)

View file

@ -0,0 +1,33 @@
import enum
from typing import List
from sqlalchemy import Boolean, Enum as SAEnum, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql
from crudkit.core.base import Base, CRUDMixin
class StatusCategory(str, enum.Enum):
ACTIVE = "Active"
AVAILABLE = "Available"
PENDING = "Pending"
FAULTED = "Faulted"
DECOMMISSIONED = "Decommissioned"
DISPOSED = "Disposed"
ADMINISTRATIVE = "Administrative"
status_type = SAEnum(
StatusCategory,
name="status_category_enum",
validate_strings=True,
)
class Status(Base, CRUDMixin):
__tablename__ = "status"
description: Mapped[str] = mapped_column(Unicode(255), nullable=False, index=True, unique=True)
category: Mapped[StatusCategory] = mapped_column(status_type, nullable=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
inventory: Mapped[List['Inventory']] = relationship('Inventory', back_populates='condition')

View file

@ -17,7 +17,8 @@ class WorkLog(Base, CRUDMixin):
contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs') contact: Mapped[Optional['User']] = relationship('User', back_populates='work_logs')
contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) contact_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True)
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan', order_by='WorkNote.timestamp.desc()') updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan',
order_by="desc(WorkNote.timestamp), desc(WorkNote.id)", lazy="selectin")
work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs') work_item: Mapped[Optional['Inventory']] = relationship('Inventory', back_populates='work_logs')
work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True) work_item_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('inventory.id'), nullable=True, index=True)

View file

@ -9,7 +9,7 @@ from crudkit.core import normalize_payload
bp_entry = Blueprint("entry", __name__) bp_entry = Blueprint("entry", __name__)
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"] ENTRY_WHITELIST = ["inventory", "user", "worklog", "room", "status"]
def _fields_for_model(model: str): def _fields_for_model(model: str):
fields: list[str] = [] fields: list[str] = []
@ -27,44 +27,37 @@ def _fields_for_model(model: str):
"notes", "notes",
"owner.id", "owner.id",
"image.filename", "image.filename",
"image.caption",
] ]
fields_spec = [ fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label", {"name": "label", "type": "display", "label": "", "row": "label",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}}, "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"}, {"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control", "placeholder": "Device Name"}, "label_attrs": {"class": "ms-2"}},
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"}, {"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control", "placeholder": "Serial Number"}, "label_attrs": {"class": "ms-2"}},
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"}, {"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control", "placeholder": "Bar Code"}, "label_attrs": {"class": "ms-2"}},
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"}, {"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "ms-2"}},
{"name": "model", "row": "device", "wrap": {"class": "col"}, {"name": "model", "row": "device", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control", "placeholder": "Model Number"}, "label": "Model #", "label_attrs": {"class": "ms-2"}},
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"}, {"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}}, "attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "ms-2"}},
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"}, {"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}}, "label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}},
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"}, "options_params": {"active__eq": True}},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, {"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"},
"label_spec": "{name} - {room_function.description}"}, "label_spec": "{name} - {room_function.description}"},
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"}, {"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"},
"type": "select", "options": [ "label_attrs": {"class": "ms-2"}, "label_spec": "{description}"},
{"label": "Deployed", "value": "Deployed"},
{"label": "Working", "value": "Working"},
{"label": "Unverified", "value": "Unverified"},
{"label": "Partially Inoperable", "value": "Partially Inoperable"},
{"label": "Inoperable", "value": "Inoperable"},
{"label": "Removed", "value": "Removed"},
{"label": "Disposed", "value": "Disposed"},
],
"label_attrs": {"class": "form-label"}, "attrs": {"class": "form-control"}},
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}", {"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"}, "template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto", "data-model": "inventory"},
"wrap": {"class": "h-100 w-100"}}, "wrap": {"class": "d-inline-block position-relative image-wrapper", "style": "min-width: 200px; min-height: 200px;"}},
{"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"}, {"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
"template": "inventory_note.html"}, "template": "inventory_note.html"},
{"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"}, {"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"},
@ -89,30 +82,44 @@ def _fields_for_model(model: str):
"title", "title",
"active", "active",
"staff", "staff",
"supervisor.id" "supervisor.id",
"inventory.label",
"inventory.brand.name",
"inventory.model",
"inventory.device_type.description",
"inventory.condition.category",
"work_logs.work_item",
"work_logs.start_time",
"work_logs.end_time",
"work_logs.complete",
] ]
fields_spec = [ fields_spec = [
{"name": "label", "row": "label", "label": "", "type": "display", {"name": "label", "row": "label", "label": "", "type": "display",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"}, "attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"}, {"name": "last_name", "label": "Last Name", "label_attrs": {"class": "ms-2"},
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}}, "attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"}, {"name": "first_name", "label": "First Name", "label_attrs": {"class": "ms-2"},
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}}, "attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
{"name": "title", "label": "Title", "label_attrs": {"class": "form-label"}, {"name": "title", "label": "Title", "label_attrs": {"class": "ms-2"},
"attrs": {"placeholder": "President of the Universe", "class": "form-control"}, "attrs": {"placeholder": "President of the Universe", "class": "form-control"},
"row": "name", "wrap": {"class": "col-3"}}, "row": "name", "wrap": {"class": "col form-floating"}},
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"}, {"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "ms-2 link-label"},
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"}, "label_spec": "{label}", "row": "details", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, "attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}},
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"}, "options_params": {"active__eq": True, "staff__eq": True}},
{"name": "location", "label": "Room", "label_attrs": {"class": "ms-2"},
"label_spec": "{name} - {room_function.description}", "label_spec": "{name} - {room_function.description}",
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}}, "row": "details", "wrap": {"class": "col form-floating"}, "attrs": {"class": "form-control"}},
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"}, {"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"}, {"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}}, "row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "inventory", "label": "Inventory", "type": "template", "row": "inventory",
"template": "user_inventory.html", "wrap": {"class": "col"}},
{"name": "work_logs", "label": "Work Logs", "row": "inventory", "type": "template",
"template": "user_worklogs.html", "wrap": {"class": "col"}},
] ]
layout = [ layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
@ -120,6 +127,7 @@ def _fields_for_model(model: str):
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}}, {"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
{"name": "checkboxes", "order": 30, "parent": "details", {"name": "checkboxes", "order": 30, "parent": "details",
"attrs": {"class": "col d-flex flex-column justify-content-end"}}, "attrs": {"class": "col d-flex flex-column justify-content-end"}},
{"name": "inventory", "order": 40, "attrs": {"class": "row"}},
] ]
elif model == "worklog": elif model == "worklog":
@ -134,26 +142,28 @@ def _fields_for_model(model: str):
"updates.id", "updates.id",
"updates.content", "updates.content",
"updates.timestamp", "updates.timestamp",
"updates.is_deleted", "updates.is_deleted"
] ]
fields_spec = [ fields_spec = [
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}", {"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}}, "attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact", {"name": "contact", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Contact",
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}}, "link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}},
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item", "options_params": {"active__eq": True}},
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, {"name": "work_item", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Work Item",
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}, "label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}},
"options_params": {"condition__nin": ["Removed", "Disposed"]}},
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps", {"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"}, "wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "Start"},
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps", {"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"}, "wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "End"},
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"}, {"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}}, "attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"}, {"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "ms-2"},
"type": "template", "template": "update_list.html"}, "type": "template", "template": "update_list.html"},
] ]
layout = [ layout = [
@ -173,18 +183,43 @@ def _fields_for_model(model: str):
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html", {"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}}, "wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"}, {"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}}, "label_attrs": {"class": "ms-2"}, "wrap": {"class": "col mb-3 form-floating"}},
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"}, {"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"}, "label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}, "label_spec": "{name}"},
{"name": "room_function", "label": "Description", "label_spec": "{description}", {"name": "room_function", "label": "Description", "label_spec": "{description}",
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"}, "row": "details",
"wrap": {"class": "col"}}, "wrap": {"class": "col form-floating"}},
] ]
layout = [ layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}}, {"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
{"name": "name", "order": 10, "attrs": {"class": "row"}}, {"name": "name", "order": 10, "attrs": {"class": "row"}},
{"name": "details", "order": 20, "attrs": {"class": "row"}}, {"name": "details", "order": 20, "attrs": {"class": "row"}},
] ]
elif model == "status":
fields_spec = [
{"name": "label", "label": "", "type": "display", "attrs": {"class": "display-6 mb-3"},
"row": "label", "wrap": {"class": "col"}, "label_spec": "{description} ({category})"},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "description", "row": "details", "label": "Description", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}},
{"name": "category", "row": "details", "label": "Category", "attrs": {"class": "form-control"},
"type": "select", "wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "options": [
{"label": "Active", "value": "Active"},
{"label": "Available", "value": "Available"},
{"label": "Pending", "value": "Pending"},
{"label": "Faulted", "value": "Faulted"},
{"label": "Decommissioned", "value": "Decommissioned"},
{"label": "Disposed", "value": "Disposed"},
{"label": "Administrative", "value": "Administrative"},
]},
]
layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
{"name": "details", "order": 10, "attrs": {"class": "row"}},
]
return (fields, fields_spec, layout) return (fields, fields_spec, layout)
@ -315,6 +350,11 @@ def init_entry_routes(app):
payload = normalize_payload(request.get_json(force=True) or {}, cls) payload = normalize_payload(request.get_json(force=True) or {}, cls)
# Strip caption for inventory so it doesn't hit Inventory(**payload)
image_caption = None
if model == "inventory":
image_caption = payload.pop("caption", None)
# Child mutations and friendly-to-FK mapping # Child mutations and friendly-to-FK mapping
updates = payload.pop("updates", []) or [] updates = payload.pop("updates", []) or []
payload.pop("delete_update_ids", None) # irrelevant on create payload.pop("delete_update_ids", None) # irrelevant on create
@ -368,6 +408,8 @@ def init_entry_routes(app):
cls = crudkit.crud.get_model(model) cls = crudkit.crud.get_model(model)
payload = normalize_payload(request.get_json(), cls) payload = normalize_payload(request.get_json(), cls)
image_caption = payload.pop("caption", None)
updates = payload.pop("updates", None) or [] updates = payload.pop("updates", None) or []
delete_ids = payload.pop("delete_update_ids", None) or [] delete_ids = payload.pop("delete_update_ids", None) or []
@ -426,6 +468,13 @@ def init_entry_routes(app):
obj = service.update(id, data=payload, actor="update_entry", commit=False) obj = service.update(id, data=payload, actor="update_entry", commit=False)
if model == "inventory" and image_caption is not None:
image_id = payload.get("image_id") or getattr(obj, "image_id", None)
if image_id:
image_cls = crudkit.crud.get_model("image")
image_svc = crudkit.crud.get_service(image_cls)
image_svc.update(image_id, {"caption": image_caption})
if model == "worklog" and (updates or delete_ids): if model == "worklog" and (updates or delete_ids):
_apply_worklog_updates(obj, updates, delete_ids) _apply_worklog_updates(obj, updates, delete_ids)

89
inventory/routes/image.py Normal file
View file

@ -0,0 +1,89 @@
from pathlib import Path
from hashlib import md5
from werkzeug.utils import secure_filename
from flask import current_app, request, abort, jsonify, url_for, Blueprint
import crudkit
bp_image = Blueprint('image', __name__, url_prefix='/api/image')
def init_image_routes(app):
@bp_image.post('/upload')
def upload_image():
"""
Accepts multipart/form-data:
- image: file
- model: optional model name (e.g "inventory")
- caption: optional caption
Saves to static/uploads/images/<model>/<hash>_filename
Creates Image row via CRUD service and returns it as JSON.
"""
file = request.files.get("image")
if not file or not file.filename:
abort(400, "missing image file")
# Optional, useful to namespace by owner model
model_name = (request.form.get("model") or "generic").lower()
# Normalize filename
orig_name = secure_filename(file.filename)
# Read bytes once so we can hash + save
raw = file.read()
if not raw:
abort(400, "empty file")
# Hash for stable-ish unique prefix
h = md5(raw).hexdigest()[:16]
stored_name = f"{h}_{orig_name}"
# Build path: static/uploads/images/<model_name>/<hash>_filename
static_root = Path(current_app.root_path) / "static"
rel_dir = Path("uploads") / "images" / model_name
abs_dir = static_root / rel_dir
abs_dir.mkdir(parents=True, exist_ok=True)
abs_path = abs_dir / stored_name
abs_path.write_bytes(raw)
# What goes in the DB: path relative to /static
rel_path = str(rel_dir / stored_name).replace("\\", "/")
caption = request.form.get("caption", "") or ""
image_id = request.form.get("image_id")
image_model = crudkit.crud.get_model('image')
image_svc = crudkit.crud.get_service(image_model)
if image_id:
# Reuse existing row instead of creating a new one
image_id_int = int(image_id)
# Make sure it exists
existing = image_svc.get(image_id_int, {})
if existing is not None:
image = image_svc.update(image_id_int, {
'filename': rel_path,
'caption': caption,
})
else:
# Fallback to create if somehow missing
image = image_svc.create({
'filename': rel_path,
'caption': caption,
})
else:
# First time: create new row
image = image_svc.create({
'filename': rel_path,
'caption': caption
})
return jsonify({
'status': 'success',
'id': image.id,
'filename': image.filename,
'caption': image.caption,
'url': url_for('static', filename=image.filename, _external=False)
}), 201
app.register_blueprint(bp_image)

View file

@ -7,6 +7,7 @@ import crudkit
from crudkit.ui.fragments import render_table from crudkit.ui.fragments import render_table
from ..models.device_type import DeviceType
from ..models.inventory import Inventory from ..models.inventory import Inventory
from ..models.work_log import WorkLog from ..models.work_log import WorkLog
@ -15,6 +16,7 @@ bp_index = Blueprint("index", __name__)
def init_index_routes(app): def init_index_routes(app):
@bp_index.get("/") @bp_index.get("/")
def index(): def index():
# 1. work log stuff (leave it)
work_log_service = crudkit.crud.get_service(WorkLog) work_log_service = crudkit.crud.get_service(WorkLog)
work_logs = work_log_service.list({ work_logs = work_log_service.list({
"complete__ne": 1, "complete__ne": 1,
@ -32,10 +34,69 @@ def init_index_routes(app):
{"field": "work_item.label", "label": "Work Item", {"field": "work_item.label", "label": "Work Item",
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}} "link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
] ]
logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"}) logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"})
return render_template("index.html", logs=logs) # 2. get device types with targets
device_type_service = crudkit.crud.get_service(DeviceType)
dt_rows = device_type_service.list({
'limit': 0,
'target__gt': 0,
'fields': [
'description',
'target'
],
"sort": "description",
})
# turn into df
device_types = pd.DataFrame([d.as_dict() for d in dt_rows])
# if nobody has targets, just show empty table
if device_types.empty:
empty_df = pd.DataFrame(columns=['id', 'description', 'target', 'actual', 'needed'])
return render_template("index.html", logs=logs, needed_inventory=empty_df)
# 3. now we can safely collect ids from the DF
dt_ids = device_types['id'].tolist()
# 4. build inventory filter
dt_filter = {
'$or': [{'device_type_id': d} for d in dt_ids],
# drop this if you decided to ignore condition
'condition.category': 'Available'
}
# 5. fetch inventory
inventory_service = crudkit.crud.get_service(Inventory)
inv_rows = inventory_service.list({
'limit': 0,
**dt_filter,
'fields': ['device_type.description'],
'sort': 'device_type.description',
})
inventory_df = pd.DataFrame([i.as_dict() for i in inv_rows])
# if there is no inventory for these device types, actual = 0
if inventory_df.empty:
device_types['actual'] = 0
device_types['needed'] = device_types['target']
return render_template("index.html", logs=logs, needed_inventory=device_types)
# 6. aggregate counts
inv_counts = (
inventory_df['device_type.description']
.value_counts()
.rename('actual')
.reset_index()
.rename(columns={'device_type.description': 'description'})
)
# 7. merge
merged = device_types.merge(inv_counts, on='description', how='left')
merged['actual'] = merged['actual'].fillna(0).astype(int)
merged['needed'] = (merged['target'] - merged['actual']).clip(lower=0)
return render_template("index.html", logs=logs, needed_inventory=merged)
@bp_index.get("/LICENSE") @bp_index.get("/LICENSE")
def license(): def license():

View file

@ -24,7 +24,7 @@ def init_listing_routes(app):
limit_qs = request.args.get("limit") limit_qs = request.args.get("limit")
page = int(request.args.get("page", 1) or 1) page = int(request.args.get("page", 1) or 1)
per_page = int(per_page_qs) if (per_page_qs and per_page_qs.isdigit()) else ( per_page = int(per_page_qs) if (per_page_qs and per_page_qs.isdigit()) else (
int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 15 int(limit_qs) if (limit_qs and limit_qs.isdigit()) else 18
) )
sort = request.args.get("sort") sort = request.args.get("sort")
fields_qs = request.args.get("fields") fields_qs = request.args.get("fields")
@ -36,7 +36,7 @@ def init_listing_routes(app):
if model.lower() == 'inventory': if model.lower() == 'inventory':
spec = {"fields": [ spec = {"fields": [
"label", "name", "barcode", "serial", "brand.name", "model", "label", "name", "barcode", "serial", "brand.name", "model",
"device_type.description", "condition", "owner.label", "location.label", "device_type.description", "condition.description", "owner.label", "location.label",
]} ]}
columns = [ columns = [
{"field": "label"}, {"field": "label"},
@ -46,7 +46,7 @@ def init_listing_routes(app):
{"field": "brand.name", "label": "Brand"}, {"field": "brand.name", "label": "Brand"},
{"field": "model"}, {"field": "model"},
{"field": "device_type.description", "label": "Device Type"}, {"field": "device_type.description", "label": "Device Type"},
{"field": "condition"}, {"field": "condition.description", "label": "Condition"},
{"field": "owner.label", "label": "Contact", {"field": "owner.label", "label": "Contact",
"link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}}, "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
{"field": "location.label", "label": "Room"}, {"field": "location.label", "label": "Room"},

View file

@ -1,89 +1,206 @@
from flask import Blueprint, render_template, url_for from datetime import datetime, timedelta
from email.utils import format_datetime
from flask import Blueprint, render_template, url_for, make_response, request
from urllib.parse import urlencode from urllib.parse import urlencode
import pandas as pd import pandas as pd
from crudkit.ui.fragments import render_table
import crudkit import crudkit
bp_reports = Blueprint("reports", __name__) bp_reports = Blueprint("reports", __name__)
def service_unavailable(detail="This feature is termporarily offline. Please try again later.", retry_seconds=3600):
retry_at = format_datetime(datetime.utcnow() + timedelta(seconds=retry_seconds))
html = render_template("errors/default.html", code=503, name="Service Unavailable", description=detail)
resp = make_response(html, 503)
resp.headers["Retry-After"] = retry_at
resp.headers["Cache-Control"] = "no-store"
return resp
def init_reports_routes(app): def init_reports_routes(app):
@bp_reports.get('/summary') @bp_reports.get('/summary')
def summary(): def summary():
inventory_model = crudkit.crud.get_model('inventory') inventory_model = crudkit.crud.get_model('inventory')
inventory_service = crudkit.crud.get_service(inventory_model) inventory_service = crudkit.crud.get_service(inventory_model)
device_type_model = crudkit.crud.get_model('devicetype')
device_type_service = crudkit.crud.get_service(device_type_model)
needs = device_type_service.list({"limit": 0, "sort": "description", "fields": ["description", "target"]})
needs = pd.DataFrame([n.as_dict() for n in needs])
rows = inventory_service.list({ rows = inventory_service.list({
"limit": 0, "limit": 0,
"sort": "device_type.description", "sort": "device_type.description",
"fields": ["id", "condition", "device_type.description"], "fields": [
"id",
"device_type.description",
"condition.category", # enum
"condition.description", # not used for pivot, but handy to have
],
}) })
df = pd.DataFrame([r.as_dict() for r in rows])
data = [r.as_dict() for r in rows]
if not data:
return render_template("summary.html", col_headers=[], table_rows=[])
df = pd.DataFrame(data)
# Dedup by id just in case you have over-eager joins
if "id" in df.columns: if "id" in df.columns:
df = df.drop_duplicates(subset="id") df = df.drop_duplicates(subset="id")
# Normalize text columns
df["device_type.description"] = (
df.get("device_type.description")
.fillna("(Unspecified)")
.astype(str)
)
# condition.category might be Enum(StatusCategory). We want the human values, e.g. "Active".
if "condition.category" in df.columns:
def _enum_value(x):
# StatusCategory is str, enum.Enum, so x.value is the nice string ("Active").
try:
return x.value
except AttributeError:
# Fallback if already a string or something weird
s = str(x)
# If someone handed us "StatusCategory.ACTIVE", fall back to the right half and title-case
return s.split(".", 1)[-1].capitalize() if s.startswith("StatusCategory.") else s
df["condition.category"] = df["condition.category"].map(_enum_value)
# Build the pivot by CATEGORY
cat_col = "condition.category"
if cat_col not in df.columns:
# No statuses at all; show a flat, zero-only pivot so the template stays sane
pt = pd.DataFrame(index=sorted(df["device_type.description"].unique()))
else:
pt = df.pivot_table( pt = df.pivot_table(
index="device_type.description", index="device_type.description",
columns="condition", columns=cat_col,
values="id", values="id",
aggfunc="count", aggfunc="count",
fill_value=0, fill_value=0,
) )
# Reorder/exclude like before if "target" in needs.columns:
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"] needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64")
exclude = ["Removed", "Disposed"] needs = needs.fillna({"target": pd.NA})
cols = [c for c in order if c in pt.columns] + [c for c in pt.columns if c not in order and c not in exclude] pt = pt.merge(needs, left_index=True, right_on="description")
pt = pt[cols] # Make the human label the index so the left-most column renders as names, not integers
if "description" in pt.columns:
pt = pt.set_index("description")
# Drop zero-only rows # Keep a handle on the category columns produced by the pivot BEFORE merge
pt = pt.loc[(pt != 0).any(axis=1)] category_cols = list(df[cat_col].unique()) if cat_col in df.columns else []
category_cols = [c for c in category_cols if c in pt.columns]
# Cast only count columns to int
if category_cols:
pt[category_cols] = pt[category_cols].fillna(0).astype("int64")
# And make sure target is integer too (nullable so missing stays missing)
if "target" in pt.columns:
pt["target"] = pd.to_numeric(pt["target"], errors="coerce").astype("Int64")
# Column ordering: show the operationally meaningful ones first, hide junk unless asked
preferred_order = ["Active", "Available", "Pending", "Faulted", "Decommissioned"]
exclude_labels = {"Disposed", "Administrative"}
# Only tread the category columns as count columns
count_cols = [c for c in category_cols if c not in exclude_labels]
ordered = [c for c in preferred_order if c in count_cols] + [c for c in count_cols if c not in preferred_order]
# Planning columns: keep them visible, not part of totals
planning_cols = []
if "target" in pt.columns:
# Derive on_hand/need for convenience; Available might not exist in tiny datasets
on_hand = pt[ordered].get("Available")
if on_hand is not None:
pt["on_hand"] = on_hand
pt["need"] = (pt["target"].fillna(0) - pt["on_hand"]).clip(lower=0).astype("Int64")
planning_cols = ["target", "on_hand", "need"]
else:
planning_cols = ["target"]
# Reindex to the exact list well render, so headers and cells are guaranteed to match
if not pt.empty:
pt = pt.reindex(columns=ordered + planning_cols)
# Keep rows that have any counts OR have a target (so planning rows with zero on-hand don't vanish)
if pt.shape[1] > 0:
keep_mask = (pt[ordered] != 0).any(axis=1) if ordered else False
if "target" in pt.columns:
keep_mask = keep_mask | pt["target"].notna()
pt = pt.loc[keep_mask]
# Totals # Totals
pt["Total"] = pt.sum(axis=1) if not pt.empty and ordered:
total_row = pt.sum(axis=0).to_frame().T # Per-row totals (counts only)
total_row.index = ["Total"] pt["Total"] = pt[ordered].sum(axis=1)
# Build totals row (counts only).
total_row = pd.DataFrame([pt[ordered].sum()], index=["Total"])
total_row["Total"] = total_row[ordered].sum(axis=1)
pt = pd.concat([pt, total_row], axis=0) pt = pd.concat([pt, total_row], axis=0)
# Names off # Strip pandas names
pt.index.name = None pt.index.name = None
pt.columns.name = None pt.columns.name = None
# Build link helpers. url_for can't take dotted kwarg keys, so build query strings. # Construct headers from the exact columns in the pivot (including Total if present)
base_list_url = url_for("listing.show_list", model="inventory") base_list_url = url_for("listing.show_list", model="inventory")
def q(params: dict | None):
return f"{base_list_url}?{urlencode(params)}" if params else None
def q(h): columns_for_render = list(pt.columns) if not pt.empty else []
return f"{base_list_url}?{urlencode(h)}" if h else None
# Prettu display labels for headers (keys stay raw)
friendly = {
"target": "Target",
"on_hand": "On Hand",
"need": "Need",
"Total": "Total",
}
def label_for(col: str) -> str:
return friendly.get(col, col)
# Column headers with links (except Total)
col_headers = [] col_headers = []
for col in pt.columns.tolist(): for col in columns_for_render:
if col == "Total": # Only make category columns clickable; planning/Total are informational
col_headers.append({"label": col, "href": None}) if col == "Total" or col in planning_cols:
col_headers.append({"label": label_for(col), "href": None})
else: else:
col_headers.append({"label": col, "href": q({"condition": col})}) col_headers.append({"label": label_for(col), "href": q({cat_col: col})})
# Rows with header links and cell links # Build rows. Cells iterate over the SAME list used for headers. No surprises.
table_rows = [] table_rows = []
for idx in pt.index.tolist(): index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique())
# Row header link: only if not Total for idx in index_for_render:
if idx == "Total": is_total_row = (idx == "Total")
row_href = None row_href = None if is_total_row else q({"device_type.description": idx})
else:
row_href = q({"device_type.description": idx})
# Cells: combine filters, respecting Total row/col rules
cells = [] cells = []
for col in pt.columns.tolist(): for col in columns_for_render:
val = int(pt.at[idx, col]) # Safe fetch
val = pt.at[idx, col] if (not pt.empty and idx in pt.index and col in pt.columns) else (0 if col in ordered or col == "Total" else pd.NA)
# Pretty foramtting: counts/Total as ints; planning may be nullable
if col in ordered or col == "Total":
val = int(val) if pd.notna(val) else 0
s = f"{val:,}"
else:
# planning cols: show blank for <NA>, integer otherwise
if pd.isna(val):
s = ""
else:
s = f"{int(val):,}"
params = {} params = {}
if idx != "Total": if not is_total_row:
params["device_type.description"] = idx params["device_type.description"] = idx
if col != "Total": if col not in ("Total", *planning_cols):
params["condition"] = col params[cat_col] = col
href = q(params) if params else None # None for Total×Total href = q(params) if params else None
cells.append({"value": f"{val:,}", "href": href}) cells.append({"value": s, "href": href})
table_rows.append({"label": idx, "href": row_href, "cells": cells}) table_rows.append({"label": idx, "href": row_href, "cells": cells})
return render_template( return render_template(
@ -92,4 +209,83 @@ def init_reports_routes(app):
table_rows=table_rows, table_rows=table_rows,
) )
@bp_reports.get("/problems")
def problems():
inventory_model = crudkit.crud.get_model('inventory')
inventory_svc = crudkit.crud.get_service(inventory_model)
rows = inventory_svc.list({
"limit": 0,
"$or": [
{"owner.active__eq": False},
{"owner_id": None}
],
"fields": [
"owner.label",
"label",
"brand.name",
"model",
"device_type.description",
"location.label",
"condition"
],
})
orphans = render_table(rows, [
{"field": "owner.label", "label": "Owner", "link": {"endpoint": "entry.entry", "params": {"id": "{owner.id}", "model": "user"}}},
{"field": "label", "label": "Device"},
{"field": "brand.name", "label": "Brand"},
{"field": "model"},
{"field": "device_type.description", "label": "Device Type"},
{"field": "location.label", "label": "Location"},
{"field": "condition"},
], opts={"object_class": "inventory"})
rows = inventory_svc.list({
"fields": ["id", "name", "serial", "barcode", "brand.name", "model", "device_type.description", "owner.label", "location.label"],
"limit": 0,
"$or": [
{"name__ne": None},
{"serial__ne": None},
{"barcode__ne": None},
],
})
duplicates = pd.DataFrame([r.as_dict() for r in rows]).set_index("id", drop=True)
subset = ["name", "serial", "barcode"]
mask = (
(duplicates["name"].notna() & duplicates.duplicated("name", keep=False)) |
(duplicates["serial"].notna() & duplicates.duplicated("serial", keep=False)) |
(duplicates["barcode"].notna() & duplicates.duplicated("barcode", keep=False))
)
duplicates = duplicates.loc[mask].sort_values(subset)
# you already have this
cols = [
{"name": "name", "label": "Name"},
{"name": "serial", "label": "Serial #"},
{"name": "barcode", "label": "Bar Code"},
{"name": "brand.name", "label": "Brand"},
{"name": "model", "label": "Model"},
{"name": "device_type.description", "label": "Device Type"},
{"name": "owner.label", "label": "Owner"},
{"name": "location.label", "label": "Location"},
]
col_names = [c["name"] for c in cols if c["name"] in duplicates.columns]
col_labels = [c["label"] for c in cols if c["name"] in duplicates.columns]
out = duplicates[col_names].fillna("")
# Best for Jinja: list of dicts (each row keyed by column name)
duplicates = (
out.reset_index()
.rename(columns={"index": "id"})
.to_dict(orient="records")
)
headers_for_template = ["ID"] + col_labels
return render_template("problems.html", orphans=orphans, duplicates=duplicates, duplicate_columns=headers_for_template)
app.register_blueprint(bp_reports) app.register_blueprint(bp_reports)

View file

@ -32,7 +32,7 @@ def init_search_routes(app):
{"field": "location.label", "label": "Location"}, {"field": "location.label", "label": "Location"},
] ]
inventory_results = inventory_service.list({ inventory_results = inventory_service.list({
'notes|label|owner.label__icontains': q, 'notes|label|model|serial|barcode|name|owner.label__icontains': q,
'fields': [ 'fields': [
"label", "label",
"name", "name",

View file

@ -19,9 +19,11 @@ def init_settings_routes(app):
function_service = crudkit.crud.get_service(function_model) function_service = crudkit.crud.get_service(function_model)
room_model = crudkit.crud.get_model('room') room_model = crudkit.crud.get_model('room')
room_service = crudkit.crud.get_service(room_model) room_service = crudkit.crud.get_service(room_model)
status_model = crudkit.crud.get_model('status')
status_service = crudkit.crud.get_service(status_model)
brands = brand_service.list({"sort": "name", "limit": 0}) brands = brand_service.list({"sort": "name", "limit": 0})
device_types = device_type_service.list({"sort": "description", "limit": 0}) device_types = device_type_service.list({"sort": "description", "limit": 0, "fields": ["description", "target"]})
areas = area_service.list({"sort": "name", "limit": 0}) areas = area_service.list({"sort": "name", "limit": 0})
functions = function_service.list({"sort": "description", "limit": 0}) functions = function_service.list({"sort": "description", "limit": 0})
rooms = room_service.list({ rooms = room_service.list({
@ -42,6 +44,16 @@ def init_settings_routes(app):
], ],
opts={"object_class": 'room'}) opts={"object_class": 'room'})
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms) statuses = status_service.list({
"sort": "category",
"limit": 0,
"fields": [
"description",
"category",
],
})
statuses = render_table(statuses, opts={"object_class": 'status'})
return render_template("settings.html", brands=brands, device_types=device_types, areas=areas, functions=functions, rooms=rooms, statuses=statuses)
app.register_blueprint(bp_settings) app.register_blueprint(bp_settings)

View file

@ -0,0 +1,12 @@
from flask import Blueprint, render_template
import crudkit
bp_testing = Blueprint("testing", __name__)
def init_testing_routes(app):
@bp_testing.get('/testing')
def test_page():
return render_template('testing.html')
app.register_blueprint(bp_testing)

View file

@ -0,0 +1,222 @@
:root { --tb-h: 34px; }
/* =========================================================
GRID WIDGET (editor uses container queries, viewer does not)
========================================================= */
/* -------------------------
Shared basics (both modes)
------------------------- */
/* drawing stack */
.grid-widget [data-grid] {
position: relative;
margin-inline: auto;
}
/* Overlay elements */
.grid-widget [data-canvas],
.grid-widget [data-dot],
.grid-widget [data-coords] { position: absolute; }
.grid-widget [data-canvas]{
inset: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
pointer-events: none;
}
.grid-widget [data-dot]{
transform: translate(-50%, -50%);
z-index: 2;
pointer-events: none;
}
.grid-widget [data-coords]{
bottom: 10px;
left: 10px;
pointer-events: none;
}
/* -------------------------
Toolbar styling
------------------------- */
.grid-widget [data-toolbar].toolbar{
display: grid !important;
grid-template-rows: auto auto;
align-content: start;
gap: 0.5rem;
overflow: visible;
}
.grid-widget [data-toolbar] .toolbar-row{
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex-wrap: nowrap;
}
.grid-widget [data-toolbar] .toolbar-row--primary,
.grid-widget [data-toolbar] .toolbar-row--secondary{
overflow-x: auto;
overflow-y: hidden;
}
.grid-widget [data-toolbar] .toolbar-row--secondary{ opacity: 0.95; }
/* container query only matters in editor (set below) */
@container (min-width: 750px){
.grid-widget [data-toolbar].toolbar{
display: flex !important;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
}
.grid-widget [data-toolbar] .toolbar-row{ display: contents; }
.grid-widget [data-toolbar] .toolbar-row--primary,
.grid-widget [data-toolbar] .toolbar-row--secondary{ overflow: visible; }
}
.grid-widget [data-toolbar]::-webkit-scrollbar{ height: 8px; }
.grid-widget .toolbar-group{
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 0.5rem;
background: rgba(0,0,0,0.02);
}
.grid-widget .btn,
.grid-widget .form-control,
.grid-widget .badge{ height: var(--tb-h); }
.grid-widget [data-toolbar] .badge,
.grid-widget [data-toolbar] .input-group-text{ white-space: nowrap; }
.grid-widget .toolbar .btn{
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
}
.grid-widget .toolbar .form-control-color{ width: var(--tb-h); padding: 0; }
.grid-widget .tb-btn{ flex-direction: column; gap: 2px; line-height: 1; }
.grid-widget .tb-btn small{ font-size: 11px; opacity: 0.75; }
.grid-widget .dropdown-toggle::after{ display: none; }
.grid-widget .toolbar .btn-group .btn{ border-radius: 0; }
.grid-widget .toolbar .btn-group .btn:first-child{
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.grid-widget .toolbar .btn-group .btn:last-child{
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.grid-widget .btn-check:checked + .btn{
background: rgba(0,0,0,0.08);
border-color: rgba(0,0,0,0.18);
}
.grid-widget .dropdown-menu{
min-width: 200px;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
position: absolute;
z-index: 1000;
pointer-events: auto;
}
.grid-widget .dropdown-menu .form-range{ width: 100%; margin: 0; }
/* =========================================================
EDITOR MODE (needs container queries)
========================================================= */
.grid-widget[data-mode="editor"]{
container-type: inline-size; /* ONLY here */
min-width: 375px;
height: 100%;
display: flex;
flex-direction: column;
}
.grid-widget[data-mode="editor"] [data-grid-wrap]{
flex: 1 1 auto;
width: 100%;
min-height: 375px;
position: relative;
overflow: hidden;
}
.grid-widget[data-mode="editor"] [data-grid]{
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
z-index: 0;
}
/* Editor: toolbar should match snapped grid width */
.grid-widget[data-mode="editor"] [data-toolbar]{
width: var(--grid-maxw, 100%);
margin-inline: auto; /* center it to match the centered grid */
max-width: 100%;
align-self: center; /* don't stretch full parent width */
}
/* =========================================================
VIEWER MODE (must shrink-wrap like an <img>)
========================================================= */
.grid-widget[data-mode="viewer"]{
/* explicitly undo any containment */
container-type: normal; /* <-- the money line */
contain: none;
display: inline-block;
vertical-align: middle;
width: auto;
height: auto;
min-width: 0;
flex: none;
}
/* wrap is the sized box (JS sets px) */
.grid-widget[data-mode="viewer"] [data-grid-wrap]{
display: inline-block;
position: relative;
overflow: hidden;
line-height: 0; /* remove inline baseline gap */
}
/* grid must be in-flow and fill wrap */
.grid-widget[data-mode="viewer"] [data-grid]{
display: block;
width: 100%;
height: 100%;
cursor: default;
overflow: hidden;
}
/* viewer hides editor-only overlays */
.grid-widget[data-mode="viewer"] [data-coords],
.grid-widget[data-mode="viewer"] [data-dot]{ display: none !important; }

View file

@ -0,0 +1,12 @@
.inventory-dropdown {
border-color: rgb(222, 226, 230) !important;
overflow-y: auto;
}
.link-label {
pointer-events: none;
}
.link-label a {
pointer-events: auto;
}

View file

@ -0,0 +1,10 @@
.image-buttons {
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.image-wrapper:hover .image-buttons {
opacity: 1;
pointer-events: auto;
}

View file

@ -0,0 +1,132 @@
const DropDown = globalThis.DropDown ?? (globalThis.DropDown = {});
DropDown.utilities = {
filterList(id) {
value = document.getElementById(`${id}-filter`).value.toLowerCase();
list = document.querySelectorAll(`#${id}-dropdown li`);
list.forEach(item => {
const txt = item.textContent.toLowerCase();
if (txt.includes(value)) {
item.style.display = 'list-item';
} else {
item.style.display = 'none';
};
});
},
selectItem(id, value) {
const btn = document.getElementById(`${id}-button`);
const txt = document.getElementById(`${id}-${value}`).textContent;
const inp = document.getElementById(id);
btn.dataset.value = value;
btn.value = txt;
inp.value = value;
},
};
(() => {
const VISIBLE_ITEMS = 10;
function setMenuMaxHeight(buttonEl) {
const menu = buttonEl?.nextElementSibling;
if (!menu || !menu.classList.contains('dropdown-menu')) return;
const input = menu.querySelector('input.form-control');
const firstItem = menu.querySelector('.dropdown-item');
if (!firstItem) return;
// Measure even if the menu is closed
const computed = getComputedStyle(menu);
const wasHidden = computed.display === 'none' || computed.visibility === 'hidden';
if (wasHidden) {
menu.style.visibility = 'hidden';
menu.style.display = 'block';
}
const inputH = input ? input.getBoundingClientRect().height : 0;
const itemH = firstItem.getBoundingClientRect().height || 0;
const itemCount = Math.min(
VISIBLE_ITEMS,
menu.querySelectorAll('.dropdown-item').length
);
const target = Math.ceil(inputH + itemH * itemCount);
menu.style.maxHeight = `${target + 10}px`;
menu.style.overflowY = 'auto';
if (wasHidden) {
menu.style.display = '';
menu.style.visibility = '';
}
}
function onShow(e) {
// Bootstrap delegated events: currentTarget is document, useless here.
const source = e.target;
// Sanity check: make sure this is an Element before using .closest
if (!(source instanceof Element)) {
console.warn('Event target is not an Element:', source);
return;
}
// Whatever you were doing before
setMenuMaxHeight(source);
// Walk up to the element with data-field
const fieldElement = source.closest('[data-field]');
if (!fieldElement) {
console.warn('No [data-field] ancestor found for', source);
return;
}
const fieldName = fieldElement.dataset.field;
if (!fieldName) {
console.warn('Element has no data-field value:', fieldElement);
return;
}
const input = document.getElementById(`${fieldName}-filter`);
if (!input) {
console.warn(`No element found with id "${fieldName}-filter"`);
return;
}
// Let Bootstrap finish its show animation / DOM fiddling
setTimeout(() => {
input.focus();
if (typeof input.select === 'function') {
input.select();
}
}, 0);
}
function onResize() {
document.querySelectorAll('.dropdown-toggle[data-bs-toggle="dropdown"]').forEach(btn => {
const menu = btn.nextElementSibling;
if (menu && menu.classList.contains('dropdown-menu') && menu.classList.contains('show')) {
setMenuMaxHeight(btn);
}
});
}
function init(root = document) {
// Delegate so dynamically-added dropdowns work too
root.addEventListener('shown.bs.dropdown', onShow);
window.addEventListener('resize', onResize);
}
// Expose for manyal calls or tests
DropDown.utilities.setMenuMaxHeight = setMenuMaxHeight;
DropDown.init = init;
// Auto-init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
init();
}
})();

View file

@ -0,0 +1,438 @@
import { SHAPE_DEFAULTS } from "./widget-core.js";
function shortenKeys(shapes) {
const keyMap = {
type: 't',
points: 'p',
color: 'cl', // avoid collision with x2
strokeWidth: 'sw',
strokeOpacity: 'so',
fillOpacity: 'fo',
fill: 'f',
x: 'x',
y: 'y',
w: 'w',
h: 'h',
x1: 'a',
y1: 'b',
x2: 'c',
y2: 'd'
};
return shapes.map((shape) => {
const out = {};
for (const key of Object.keys(shape)) {
const newKey = keyMap[key] || key;
out[newKey] = shape[key];
}
return out;
});
}
function shortenShapes(shapes) {
const shapeMap = { path: 'p', line: 'l', rect: 'r', ellipse: 'e', stateChange: 's' };
return shapes.map(shape => ({
...shape,
type: shapeMap[shape.type] || shape.type
}));
}
function collapseStateChanges(shapes) {
const out = [];
let pending = null;
const flush = () => {
if (pending) out.push(pending);
pending = null;
};
for (const shape of shapes) {
if (shape.type === "stateChange") {
if (!pending) pending = { ...shape };
else {
for (const [k, v] of Object.entries(shape)) {
if (k !== "type") pending[k] = v;
}
}
continue;
}
flush();
out.push(shape);
}
flush();
return out;
}
function stateCode(shapes, SHAPE_DEFAULTS) {
const state = {
...SHAPE_DEFAULTS,
color: "#000000",
fill: false,
fillOpacity: 1
};
const styleKeys = Object.keys(state);
const out = [];
for (const shape of shapes) {
const s = { ...shape };
const stateChange = {};
for (const key of styleKeys) {
if (!(key in s)) continue;
if (s[key] !== state[key]) {
stateChange[key] = s[key];
state[key] = s[key];
}
delete s[key];
}
if (Object.keys(stateChange).length > 0) {
out.push({ type: "stateChange", ...stateChange });
}
out.push(s);
}
return out;
}
function computeDeltas(shapes) {
const q = 100;
const out = [];
let prevKind = null;
let prevBR = null;
let prevLineEnd = null;
const MAX_DOC_COORD = 1_000_000;
const MAX_INT = MAX_DOC_COORD * q;
const clampInt = (v) => {
if (!Number.isFinite(v)) return 0;
if (v > MAX_INT) return MAX_INT;
if (v < -MAX_INT) return -MAX_INT;
return v;
};
const toInt = (n) => clampInt(Math.round(Number(n) * q));
const resetRun = () => {
prevKind = null;
prevBR = null;
prevLineEnd = null;
};
for (const shape of shapes) {
if (shape.type === "stateChange") {
out.push(shape);
resetRun();
continue;
}
if (shape.type === "path") {
const s = { ...shape };
if (!Array.isArray(s.points) || s.points.length === 0) {
out.push(s);
resetRun();
continue;
}
const pts = [toInt(s.points[0].x), toInt(s.points[0].y)];
let prev = s.points[0];
for (let i = 1; i < s.points.length; i++) {
const cur = s.points[i];
pts.push(toInt(cur.x - prev.x), toInt(cur.y - prev.y));
prev = cur;
}
s.points = pts;
out.push(s);
resetRun();
continue;
}
if (shape.type === "line") {
const s = { ...shape };
const x1 = toInt(s.x1), y1 = toInt(s.y1);
const x2 = toInt(s.x2), y2 = toInt(s.y2);
let arr;
if (prevKind !== "line" || !prevLineEnd) {
arr = [x1, y1, x2 - x1, y2 - y1];
} else {
arr = [x1 - prevLineEnd.x2, y1 - prevLineEnd.y2, x2 - x1, y2 - y1];
}
prevKind = "line";
prevLineEnd = { x2, y2 };
delete s.x1; delete s.y1; delete s.x2; delete s.y2;
s.points = arr;
out.push(s);
continue;
}
if (shape.type === "rect" || shape.type === "ellipse") {
const s = { ...shape };
const x = toInt(s.x), y = toInt(s.y);
const w = toInt(s.w), h = toInt(s.h);
let arr;
if (prevKind !== s.type || !prevBR) {
arr = [x, y, w, h];
} else {
arr = [x - prevBR.x, y - prevBR.y, w, h];
}
prevKind = s.type;
prevBR = { x: x + w, y: y + h };
delete s.x; delete s.y; delete s.w; delete s.h;
s.points = arr;
out.push(s);
continue;
}
out.push(shape);
resetRun();
}
return out;
}
function encodeRuns(shapes) {
const out = [];
let run = null;
const flush = () => {
if (!run) return;
out.push(run);
run = null;
};
for (const shape of shapes) {
if (shape.type === 'path' || shape.type === 'stateChange') {
flush();
out.push(shape);
continue;
}
if (!run) {
run = { ...shape, points: [...shape.points] };
continue;
}
if (shape.type === run.type) {
run.points.push(...shape.points);
} else {
flush();
run = { ...shape, points: [...shape.points] };
}
}
flush();
return out;
}
function encodeStates(shapes) {
return shapes.map(shape => {
if (shape.type !== 'stateChange') return shape;
const re = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
let newShape = {};
Object.keys(shape).forEach(key => {
if (key === 'strokeOpacity' || key === 'strokeWidth' || key === 'fillOpacity') {
const v = Number(shape[key]);
if (Number.isFinite(v))
newShape[key] = Math.round(v * 100);
} else if (key === 'color') {
newShape[key] = re.test(shape[key]) ? shape[key] : '#000000';
} else if (key === 'fill') {
newShape[key] = !!shape[key];
}
});
return { ...shape, ...newShape };
});
}
export function encode({ cellSize, shapes, stripCaches, SHAPE_DEFAULTS }) {
if (!SHAPE_DEFAULTS) SHAPE_DEFAULTS = { strokeWidth: 0.12, strokeOpacity: 1, fillOpacity: 1 };
const cs = Number(cellSize);
const safeCellSize = Number.isFinite(cs) && cs >= 1 ? cs : 25;
const safeShapes = Array.isArray(shapes) ? shapes : [];
const stripped = (typeof stripCaches === "function") ? stripCaches(safeShapes) : safeShapes;
const payload = {
v: 1,
cs: safeCellSize,
q: 100,
d: {
cl: "#000000",
f: false,
sw: 12,
so: 100,
fo: 100
},
s: shortenKeys(
shortenShapes(
encodeStates(
encodeRuns(
computeDeltas(
collapseStateChanges(
stateCode(stripped, SHAPE_DEFAULTS)
)
)
)
)
)
)
};
return payload;
}
function decodePath(arr, q) {
let x = arr[0], y = arr[1];
const pts = [{ x: x / q, y: y / q }];
for (let i = 2; i < arr.length; i += 2) {
x += arr[i];
y += arr[i + 1];
pts.push({ x: x / q, y: y / q });
}
return pts;
}
export function decode(doc) {
const q = Number(doc?.q) || 100;
const cs = Number(doc?.cs) || 25;
const defaults = doc?.d || {};
const state = {
color: defaults.cl ?? "#000000",
fill: !!defaults.f,
strokeWidth: (Number(defaults.sw) ?? 12) / 100,
strokeOpacity: (Number(defaults.so) ?? 100) / 100,
fillOpacity: (Number(defaults.fo) ?? 100) / 100
};
const outShapes = [];
const num01 = (v, fallback) => {};
const applyStateChange = (op) => {
if ("cl" in op) state.color = op.cl;
if ("f" in op) state.fill = !!op.f;
if ("sw" in op) state.strokeWidth = num01(op.sw, state.strokeWidth * 100) / 100;
if ("so" in op) state.strokeOpacity = num01(op.so, state.strokeOpacity * 100) / 100;
if ("fo" in op) state.fillOpacity = num01(op.fo, state.fillOpacity * 100) / 100;
};
const ops = Array.isArray(doc?.s) ? doc.s : [];
for (const op of ops) {
if (!op || typeof op !== "object") continue;
const t = op.t;
if (t === "s") {
applyStateChange(op);
continue;
}
const arr = op.p;
if (!Array.isArray(arr) || arr.length === 0) continue;
if (t === "p") {
if (arr.length < 2 || (arr.length % 2) !== 0) continue;
outShapes.push({
type: "path",
points: decodePath(arr, q),
color: state.color,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
continue;
}
if ((arr.length % 4) !== 0) continue;
if (t === "l") {
let prevX2 = null, prevY2 = null;
for (let i = 0; i < arr.length; i += 4) {
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
let x1, y1;
if (i === 0) {
x1 = a; y1 = b;
} else {
x1 = prevX2 + a;
y1 = prevY2 + b;
}
const x2 = x1 + c;
const y2 = y1 + d;
outShapes.push({
type: "line",
x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q,
color: state.color,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
prevX2 = x2; prevY2 = y2;
}
continue;
}
if (t === "r" || t === "e") {
let prevBRx = null, prevBRy = null;
for (let i = 0; i < arr.length; i += 4) {
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
let x, y;
if (i === 0) {
x = a; y = b;
} else {
x = prevBRx + a;
y = prevBRy + b;
}
const w = c, h = d;
outShapes.push({
type: (t === "r") ? "rect" : "ellipse",
x: x / q, y: y / q, w: w / q, h: h / q,
color: state.color,
fill: state.fill,
fillOpacity: state.fillOpacity,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
prevBRx = x + w;
prevBRy = y + h;
}
continue;
}
}
return {
version: Number(doc?.v) || 1,
cellSize: cs,
shapes: outShapes
};
}

View file

@ -0,0 +1,117 @@
export function dist2(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return dx * dx + dy * dy;
}
export function pointToSegmentDist2(p, a, b) {
const vx = b.x - a.x, vy = b.y - a.y;
const wx = p.x - a.x, wy = p.y - a.y;
const c1 = vx * wx + vy * wy;
if (c1 <= 0) return dist2(p, a);
const c2 = vx * vx + vy * vy;
if (c2 <= c1) return dist2(p, b);
const t = c1 / c2;
const proj = { x: a.x + t * vx, y: a.y + t * vy };
return dist2(p, proj);
}
function hitShape(p, s, tol) {
if (s.type === 'line') {
const a = { x: s.x1, y: s.y1 };
const b = { x: s.x2, y: s.y2 };
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
const t = tol + sw;
return pointToSegmentDist2(p, a, b) <= (t * t);
}
if (s.type === 'path') {
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
if (!pts || pts.length < 2) return false;
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
const t = tol + sw;
for (let i = 0; i < pts.length - 1; i++) {
if (pointToSegmentDist2(p, pts[i], pts[i + 1]) <= (t * t)) return true;
}
return false;
}
if (s.type === 'rect') {
return hitRect(p, s, tol);
}
if (s.type === 'ellipse') {
return hitEllipse(p, s, tol);
}
return false;
}
function hitRect(p, r, tol) {
const x1 = r.x, y1 = r.y, x2 = r.x + r.w, y2 = r.y + r.h;
const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
const inside = (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY);
if (r.fill) {
return (p.x >= minX - tol && p.x <= maxX + tol && p.y >= minY - tol && p.y <= maxY + tol);
}
if (!inside) {
if (p.x < minX - tol || p.x > maxX + tol || p.y < minY - tol || p.y > maxY + tol) return false;
}
const nearLeft = Math.abs(p.x - minX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
const nearRight = Math.abs(p.x - maxX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
const nearTop = Math.abs(p.y - minY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
const nearBottom = Math.abs(p.y - maxY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
return nearLeft || nearRight || nearTop || nearBottom;
}
function hitEllipse(p, e, tol) {
const cx = e.x + e.w / 2;
const cy = e.y + e.h / 2;
const rx = Math.abs(e.w / 2);
const ry = Math.abs(e.h / 2);
if (rx <= 0 || ry <= 0) return false;
const nx = (p.x - cx) / rx;
const ny = (p.y - cy) / ry;
const d = nx * nx + ny * ny;
if (e.fill) {
const rx2 = (rx + tol);
const ry2 = (ry + tol);
const nnx = (p.x - cx) / rx2;
const nny = (p.y - cy) / ry2;
return (nnx * nnx + nny * nny) <= 1;
}
const minR = Math.max(1e-6, Math.min(rx, ry));
const band = tol / minR;
return Math.abs(d - 1) <= Math.max(0.02, band);
}
export function pickShapeAt(docPt, shapes, cellSize, opts = {}) {
const pxTol = opts.pxTol ?? 6;
const cs = Number(cellSize);
const safeCellSize = (Number.isFinite(cs) && cs > 0) ? cs : 25;
const tol = pxTol / safeCellSize;
for (let i = shapes.length - 1; i >= 0; i--) {
const s = shapes[i];
if (!s) continue;
if (hitShape(docPt, s, tol)) {
return { index: i, shape: s };
}
}
return null;
}

View file

@ -0,0 +1,23 @@
(function bindGridGlobalOnce() {
if (window.__gridGlobalBound) return;
window.__gridGlobalBound = true;
window.activeGridWidget = null;
// Keydown (undo/redo, escape)
document.addEventListener('keydown', (e) => {
const w = window.activeGridWidget;
if (!w || typeof w.handleKeyDown !== 'function') return;
w.handleKeyDown(e);
});
// Pointer finalize (for drawing finishing outside the element)
const forwardPointer = (e) => {
const w = window.__gridPointerOwner || window.activeGridWidget;
if (!w || typeof w.handleGlobalPointerUp !== 'function') return;
w.handleGlobalPointerUp(e);
};
window.addEventListener('pointerup', forwardPointer, { capture: true });
window.addEventListener('pointercancel', forwardPointer, { capture: true });
})();

View file

@ -0,0 +1,46 @@
import './global-bindings.js';
import { initGridWidget } from './widget-init.js';
const GRID_BOOT = window.__gridBootMap || (window.__gridBootMap = new WeakMap());
(function autoBootGridWidgets() {
function bootRoot(root) {
if (GRID_BOOT.has(root)) return;
GRID_BOOT.set(root, true);
const mode = root.dataset.mode || 'editor';
const storageKey = root.dataset.storageKey || root.dataset.key || 'gridDoc';
const api = initGridWidget(root, { mode, storageKey });
root.__gridApi = api;
}
document.querySelectorAll('[data-grid-widget]').forEach(bootRoot);
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.removedNodes) {
if (!(node instanceof Element)) continue;
const roots = [];
if (node.matches?.('[data-grid-widget]')) roots.push(node);
node.querySelectorAll?.('[data-grid-widget]').forEach(r => roots.push(r));
for (const r of roots) {
r.__gridApi?.destroy?.();
r.__gridApi = null;
GRID_BOOT.delete(r);
}
}
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches?.('[data-grid-widget]')) bootRoot(node);
node.querySelectorAll?.('[data-grid-widget]').forEach(bootRoot);
}
}
});
mo.observe(document.documentElement, { childList: true, subtree: true });
})();

View file

@ -0,0 +1,42 @@
import { pointToSegmentDist2 } from './geometry.js';
export function simplifyRDP(points, epsilon) {
if (!Array.isArray(points) || points.length < 3) return points || [];
const e = Number(epsilon);
const eps2 = Number.isFinite(e) ? e * e : 0;
function rdp(first, last, out) {
let maxD2 = 0;
let idx = -1;
const a = points[first];
const b = points[last];
for (let i = first + 1; i < last; ++i) {
const d2 = pointToSegmentDist2(points[i], a, b);
if (d2 > maxD2) {
maxD2 = d2;
idx = i;
}
}
if (maxD2 > eps2 && idx !== -1) {
rdp(first, idx, out);
out.pop();
rdp(idx, last, out);
} else {
out.push(a, b);
}
}
const out = [];
rdp(0, points.length - 1, out);
const deduped = [out[0]];
for (let i = 1; i < out.length; i++) {
const prev = deduped[deduped.length - 1];
const cur = out[i];
if (prev.x !== cur.x || prev.y !== cur.y) deduped.push(cur);
}
return deduped;
}

View file

@ -0,0 +1,90 @@
export function catmullRomResample(points, {
alpha = 0.5,
samplesPerSeg = 8,
maxSamplesPerSeg = 32,
minSamplesPerSeg = 4,
closed = false,
maxOutputPoints = 5000
} = {}) {
if (!Array.isArray(points) || points.length < 2) return points || [];
const dist = (a, b) => {
const dx = b.x - a.x, dy = b.y - a.y;
return Math.hypot(dx, dy);
};
const tj = (ti, pi, pj) => ti + Math.pow(dist(pi, pj), alpha);
const lerp2 = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
function evalSegment(p0, p1, p2, p3, t) {
let t0 = 0;
let t1 = tj(t0, p0, p1);
let t2 = tj(t1, p1, p2);
let t3 = tj(t2, p2, p3);
const eps = 1e-6;
if (t1 - t0 < eps) t1 = t0 + eps;
if (t2 - t1 < eps) t2 = t1 + eps;
if (t3 - t2 < eps) t3 = t2 + eps;
const u = t1 + (t2 - t1) * t;
const A1 = lerp2(p0, p1, (u - t0) / (t1 - t0));
const A2 = lerp2(p1, p2, (u - t1) / (t2 - t1));
const A3 = lerp2(p2, p3, (u - t2) / (t3 - t2));
const B1 = lerp2(A1, A2, (u - t0) / (t2 - t0));
const B2 = lerp2(A2, A3, (u - t1) / (t3 - t1));
const C = lerp2(B1, B2, (u - t1) / (t2 - t1));
return C;
}
const src = (points || []).filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
if (src.length < 2) return src;
const n = src.length;
const get = (i) => {
if (closed) {
const k = (i % n + n) % n;
return src[k];
}
if (i < 0) return src[0];
if (i >= n) return src[n - 1];
return src[i];
};
const out = [];
const pushPoint = (p) => {
if (out.length >= maxOutputPoints) return false;
const prev = out[out.length - 1];
if (!prev || prev.x !== p.x || prev.y !== p.y) out.push(p);
return true;
};
pushPoint({ x: src[0].x, y: src[0].y });
const segCount = closed ? n : (n - 1);
for (let i = 0; i < segCount; i++) {
const p0 = get(i - 1);
const p1 = get(i);
const p2 = get(i + 1);
const p3 = get(i + 2);
const segLen = dist(p1, p2);
const adaptive = Math.round(samplesPerSeg * Math.max(1, segLen * 0.75));
const steps = Math.max(minSamplesPerSeg, Math.min(maxSamplesPerSeg, adaptive));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const p = evalSegment(p0, p1, p2, p3, t);
if (!pushPoint(p)) return out;
}
}
return out;
}

View file

@ -0,0 +1,472 @@
import { catmullRomResample } from './spline.js';
export const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
export const SHAPE_DEFAULTS = {
strokeWidth: 0.12,
strokeOpacity: 1,
fillOpacity: 1
};
export function createWidgetCore(env) {
let {
root, mode, storageKey,
gridEl, canvasEl,
viewerOffset = { x: 0, y: 0 },
} = env;
let doc = env.doc || structuredClone(DEFAULT_DOC);
let cellSize = Number(env.cellSize) || 25;
let shapes = Array.isArray(env.shapes) ? env.shapes : [];
let selectedShape = env.selectedShape || null;
let ctx = null;
let dpr = 1;
function clamp01(n, fallback = 1) {
const x = Number(n);
return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
}
function isFiniteNum(n) { return Number.isFinite(Number(n)); }
// Document and shape lifecycle
function loadDoc() {
try {
const raw = env.loadRaw
? env.loadRaw()
: localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : structuredClone(DEFAULT_DOC);
} catch {
return structuredClone(DEFAULT_DOC);
}
}
function saveDoc(nextDoc = doc) {
const safeDoc = {
...nextDoc,
shapes: stripCaches(Array.isArray(nextDoc.shapes) ? nextDoc.shapes : [])
};
doc = safeDoc;
const raw = JSON.stringify(safeDoc);
try {
if (env.saveRaw) env.saveRaw(raw);
else localStorage.setItem(storageKey, raw);
} catch { }
}
function setDoc(nextDoc) {
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
cellSize = Number(d.cellSize) || 25;
shapes = rebuildPathCaches(
sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : [])
);
doc = { version: Number(d.version) || 1, cellSize, shapes };
if (mode === 'editor') {
saveDoc(doc);
}
requestAnimationFrame(() => resizeAndSetupCanvas());
}
function sanitizeShapes(list) {
const allowed = new Set(['rect', 'ellipse', 'line', 'path']);
const normStroke = (v, fallback = 0.12) => {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.max(0, n);
};
return list.flatMap((s) => {
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
const color = typeof s.color === 'string' ? s.color : '#000000';
const fillOpacity = clamp01(s.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
const strokeOpacity = clamp01(s.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
if (s.type === 'line') {
if (!['x1', 'y1', 'x2', 'y2'].every(k => isFiniteNum(s[k]))) return [];
return [{
type: 'line',
x1: +s.x1, y1: +s.y1, x2: +s.x2, y2: +s.y2,
color,
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
strokeOpacity
}];
}
if (s.type === 'path') {
if (!Array.isArray(s.points) || s.points.length < 2) return [];
const points = s.points.flatMap(p => {
if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) return [];
return [{ x: +p.x, y: +p.y }];
});
if (points.length < 2) return [];
return [{
type: 'path',
points,
color,
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
strokeOpacity
}];
}
if (!['x', 'y', 'w', 'h'].every(k => isFiniteNum(s[k]))) return [];
return [{
type: s.type,
x: +s.x, y: +s.y, w: +s.w, h: +s.h,
color,
fill: !!s.fill,
fillOpacity,
strokeOpacity,
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth)
}];
});
}
function stripCaches(shapes) {
return shapes.map(s => {
if (s.type === 'path') {
return {
type: 'path',
points: s.points,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'line') {
return {
type: 'line',
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'rect' || s.type === 'ellipse') {
return {
type: s.type,
x: s.x, y: s.y, w: s.w, h: s.h,
color: s.color,
fill: !!s.fill,
fillOpacity: s.fillOpacity,
strokeOpacity: s.strokeOpacity,
strokeWidth: s.strokeWidth
};
}
return s; // shouldn't happen
});
}
function rebuildPathCaches(list) {
const MIN_PTS_FOR_SMOOTH = 4;
const MIN_LEN = 2;
const MIN_TURN = 0.15;
return list.map(s => {
if (s.type !== 'path') return s;
const pts = s.points;
if (!Array.isArray(s.points) || pts.length < 2) return s;
if (!pts.every(p => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return s;
if (pathLength(pts) < MIN_LEN) return s;
if (pts.length < MIN_PTS_FOR_SMOOTH) return s;
if (MIN_TURN != null && totalTurning(pts) < MIN_TURN) return s;
const renderPoints = catmullRomResample(s.points, {
alpha: 0.5,
samplesPerSeg: 10,
maxSamplesPerSeg: 40,
minSamplesPerSeg: 6,
closed: false,
maxOutputPoints: 4000
});
return {
...s,
...(renderPoints?.length >= 2 ? { renderPoints } : {})
};
});
}
function totalTurning(points) {
let sum = 0;
for (let i = 1; i < points.length - 1; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const p2 = points[i + 1];
const v1x = p1.x - p0.x;
const v1y = p1.y - p0.y;
const v2x = p2.x - p1.x;
const v2y = p2.y - p1.y;
const len1 = Math.hypot(v1x, v1y);
const len2 = Math.hypot(v2x, v2y);
if (len1 === 0 || len2 === 0) continue;
const cross = Math.abs(v1x * v2y - v1y * v2x);
sum += cross / (len1 * len2);
}
return sum;
}
function pathLength(pts) {
let L = 0;
for (let i = 1; i < pts.length; i++) {
const dx = pts[i].x - pts[i - 1].x;
const dy = pts[i].y - pts[i - 1].y;
L += Math.hypot(dx, dy);
}
return L;
}
function getShapesBounds(shapes) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const expand = (x1, y1, x2, y2) => {
minX = Math.min(minX, x1);
minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2);
maxY = Math.max(maxY, y2);
};
for (const s of shapes || []) {
if (!s) continue;
if (s.type === 'rect' || s.type === 'ellipse') {
expand(s.x, s.y, s.x + s.w, s.y + s.h);
} else if (s.type === 'line') {
expand(
Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
Math.max(s.x1, s.x2), Math.max(s.y1, s.y2)
);
} else if (s.type === 'path') {
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
if (!pts?.length) continue;
for (const p of pts) expand(p.x, p.y, p.x, p.y);
}
}
if (!Number.isFinite(minX)) return null;
return { minX, minY, maxX, maxY };
}
// Canvas pipeline
function resizeAndSetupCanvas() {
dpr = window.devicePixelRatio || 1;
const w = gridEl.clientWidth;
const h = gridEl.clientHeight;
canvasEl.width = Math.round(w * dpr);
canvasEl.height = Math.round(h * dpr);
canvasEl.style.width = `${w}px`;
canvasEl.style.height = `${h}px`;
ctx = canvasEl.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redrawAll();
}
function clearCanvas() {
if (!ctx) return;
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
}
function drawShape(shape) {
if (!ctx) return;
const toPx = (v) => v * cellSize;
ctx.save();
ctx.strokeStyle = shape.color || '#000000';
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
if (shape.type === 'rect' || shape.type === 'ellipse') {
const x = toPx(shape.x);
const y = toPx(shape.y);
const w = toPx(shape.w);
const h = toPx(shape.h);
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
if (shape.type === 'rect') {
ctx.strokeRect(x, y, w, h);
} else {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
if (shape.fill) {
ctx.globalAlpha = clamp01(shape.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
ctx.fillStyle = shape.color;
if (shape.type === 'rect') {
ctx.fillRect(x, y, w, h);
} else {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.fill()
}
ctx.globalAlpha = 1;
}
} else if (shape.type === 'line') {
const x1 = toPx(shape.x1);
const y1 = toPx(shape.y1);
const x2 = toPx(shape.x2);
const y2 = toPx(shape.y2);
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.globalAlpha = 1;
} else if (shape.type === 'path') {
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
const pts = (shape.renderPoints && shape.renderPoints.length >= 2)
? shape.renderPoints
: shape.points;
ctx.beginPath();
ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(toPx(pts[i].x), toPx(pts[i].y));
}
ctx.stroke();
}
ctx.restore();
}
function redrawAll() {
if (!ctx || !shapes) return;
clearCanvas();
ctx.save();
if (mode !== 'editor') {
ctx.translate(viewerOffset.x, viewerOffset.y);
}
shapes.forEach(drawShape);
if (mode === 'editor' && selectedShape) {
ctx.save();
ctx.globalAlpha = 1;
ctx.setLineDash([6, 4]);
drawShape({
...selectedShape,
fill: false,
strokeWidth: Math.max(selectedShape.strokeWidth ?? 0.12, 0.12) + (2 / cellSize),
strokeOpacity: 1
});
ctx.restore();
}
ctx.restore();
}
function renderAllWithPreview(previewShape = null, dashed = true) {
if (!ctx) return;
clearCanvas();
shapes.forEach(drawShape);
if (!previewShape) return;
ctx.save();
if (dashed) ctx.setLineDash([5, 3]);
drawShape(previewShape);
ctx.restore();
}
// Coordinate conversion
function pxToGrid(v) {
return v / cellSize;
}
function pxToDocPoint(clientX, clientY) {
const rect = gridEl.getBoundingClientRect();
const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left;
const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top;
return { x: pxToGrid(x), y: pxToGrid(y) };
}
// Tool state helpers
function getActiveTool() {
const checked = root.querySelector('input[data-tool]:checked');
return checked ? checked.value : 'pen';
}
function setActiveTool(toolValue) {
const el = root.querySelector(`input[data-tool][value="${CSS.escape(toolValue)}"]`);
if (el) el.checked = true;
}
function getActiveType() {
const checked = root.querySelector('input[data-gridtype]:checked');
return checked ? checked.value : 'noGrid';
}
function setActiveType(typeValue) {
const el = root.querySelector(`input[data-gridtype][value="${CSS.escape(typeValue)}"]`);
if (el) el.checked = true;
}
return {
DEFAULT_DOC,
SHAPE_DEFAULTS,
get doc() { return doc; },
get cellSize() { return cellSize; },
get shapes() { return shapes; },
get ctx() { return ctx; },
get selectedShape() { return selectedShape; },
set selectedShape(v) { selectedShape = v; },
set viewerOffset(v) { viewerOffset = v; },
loadDoc, saveDoc, setDoc,
sanitizeShapes, stripCaches, rebuildPathCaches,
getShapesBounds,
resizeAndSetupCanvas,
redrawAll,
renderAllWithPreview,
pxToDocPoint,
getActiveTool, setActiveTool,
getActiveType, setActiveType,
clamp01, pxToGrid, isFiniteNum,
};
}

View file

@ -0,0 +1,831 @@
import { encode, decode } from './encode-decode.js';
import { dist2, pickShapeAt } from './geometry.js';
import { simplifyRDP } from './simplify.js';
import { SHAPE_DEFAULTS } from './widget-core.js';
export function initWidgetEditor(core, env) {
const { root, gridEl, gridWrapEl, toastMessage, storageKey } = env;
const MAX_HISTORY = 100;
const clearEl = root.querySelector('[data-clear]');
const colorEl = root.querySelector('[data-color]');
const coordsEl = root.querySelector('[data-coords]');
const dotEl = root.querySelector('[data-dot]');
const dotSVGEl = root.querySelector('[data-dot-svg]');
const exportEl = root.querySelector('[data-export]');
const importButtonEl = root.querySelector('[data-import-button]');
const importEl = root.querySelector('[data-import]');
const cellSizeEl = root.querySelector('[data-cell-size]');
const toolBarEl = root.querySelector('[data-toolbar]');
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
const strokeWidthEl = root.querySelector('[data-stroke-width]');
const cellSizeValEl = root.querySelector('[data-cell-size-val]');
const fillValEl = root.querySelector('[data-fill-opacity-val]');
const strokeValEl = root.querySelector('[data-stroke-opacity-val]');
const widthValEl = root.querySelector('[data-stroke-width-val]');
function bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
const sync = () => { labelEl.textContent = format(inputEl.value); };
inputEl.addEventListener('input', sync);
inputEl.addEventListener('change', sync);
sync();
}
if (cellSizeEl && cellSizeValEl) bindRangeWithLabel(cellSizeEl, cellSizeValEl, v => `${v}px`);
if (fillOpacityEl && fillValEl) bindRangeWithLabel(fillOpacityEl, fillValEl, v => `${parseInt(Number(v) * 100)}%`);
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${parseInt(Number(v) * 100)}%`);
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Math.round(Number(v) * Number(cellSizeEl.value || 0))}px`);
core.saveDoc({ ...core.doc, shapes: core.shapes });
const savedTool = localStorage.getItem(`${storageKey}:tool`);
if (savedTool) core.setActiveTool(savedTool);
const savedType = localStorage.getItem(`${storageKey}:gridType`);
if (savedType) core.setActiveType(savedType);
cellSizeEl.value = core.cellSize;
let dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
let selectedColor;
let currentFillOpacity = core.clamp01(fillOpacityEl?.value ?? 1, 1);
let currentStrokeOpacity = core.clamp01(strokeOpacityEl?.value ?? 1, 1);
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
let selectedIndex = -1;
selectedColor = colorEl?.value || '#000000';
if (dotSVGEl) {
const circle = dotSVGEl.querySelector('circle');
circle?.setAttribute('fill', selectedColor);
}
let currentShape = null;
let suppressNextClick = false;
const history = [structuredClone(core.shapes)];
let historyIndex = 0
let sizingRAF = 0;
let lastApplied = { w: 0, h: 0 };
const ro = new ResizeObserver(scheduleSnappedCellSize);
ro.observe(gridWrapEl);
setGrid();
scheduleSnappedCellSize();
let activePointerId = null;
if (toolBarEl && window.bootstrap?.Dropdown) {
toolBarEl.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
window.bootstrap.Dropdown.getOrCreateInstance(toggle, {
popperConfig(defaultConfig) {
return {
...defaultConfig,
strategy: 'fixed',
modifiers: [
...(defaultConfig.modifiers || []),
{ name: 'preventOverflow', options: { boundary: 'viewport' } },
{ name: 'flip', options: { boundary: 'viewport', padding: 8 } },
],
};
},
});
});
}
requestAnimationFrame(() => requestAnimationFrame(scheduleSnappedCellSize));
const api = {
handleKeyDown(e) {
const key = e.key.toLowerCase();
const t = e.target;
const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable);
if (isTextField) {
const isUndo = (e.ctrlKey || e.metaKey) && key === 'z';
const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey));
if (!isUndo && !isRedo) return;
}
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'y') {
e.preventDefault();
redo();
return;
}
if (key === 'escape' && currentShape) {
e.preventDefault();
currentShape = null;
core.redrawAll();
}
},
handleGlobalPointerUp(e) {
finishPointer(e);
},
cancelStroke() { cancelStroke(); }
};
function destroy() {
if (window.activeGridWidget === api) window.activeGridWidget = null;
currentShape = null;
activePointerId = null;
try {
if (window.__gridPointerId != null && gridEl.hasPointerCapture?.(window.__gridPointerId)) {
gridEl.releasePointerCapture(window.__gridPointerId);
}
} catch { }
if (window.__gridPointerOwner === api) {
window.__gridPointerOwner = null;
window.__gridPointerId = null;
}
ro.disconnect();
}
api.destroy = destroy;
root.addEventListener('focusin', () => { window.activeGridWidget = api; });
root.addEventListener('pointerdown', () => {
window.activeGridWidget = api;
}, { capture: true });
function setGrid() {
const type = core.getActiveType();
gridEl.style.backgroundImage = "";
gridEl.style.backgroundSize = "";
gridEl.style.backgroundPosition = "";
gridEl.style.boxShadow = "none";
dotEl.classList.add('d-none');
// Minor dots
const dotPx = Math.max(1, Math.round(core.cellSize * 0.08));
const minorColor = '#ddd';
// Major dots (every 5 cells)
const majorStep = core.cellSize * 5;
const majorDotPx = Math.max(dotPx + 1, Math.round(core.cellSize * 0.12));
const majorColor = '#c4c4c4';
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
if (type === 'fullGrid') {
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${core.cellSize}px ${core.cellSize}px`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px ${majorStep / 2}px, ${core.cellSize / 2}px ${core.cellSize / 2}px`;
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
} else if (type === 'verticalGrid') {
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
gridEl.style.backgroundSize = `${majorStep}px 100%, ${core.cellSize}px 100%`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px 0px, ${core.cellSize / 2}px 0px`;
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
} else if (type === 'horizontalGrid') {
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${core.cellSize}px`;
gridEl.style.backgroundPosition =
`0px ${majorStep / 2}px, 0px ${core.cellSize / 2}px`;
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
} else { // noGrid
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
}
}
function isInsideRect(clientX, clientY, rect) {
return clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom;
}
function finishPointer(e) {
if (window.__gridPointerOwner !== api) return;
if (!currentShape) return;
if (e.pointerId !== activePointerId) return;
onPointerUp(e);
activePointerId = null;
window.__gridPointerOwner = null;
window.__gridPointerId = null;
}
function penAddPoint(shape, clientX, clientY, minStep = 0.02, maxDtMs = 16) {
const p = core.pxToDocPoint(clientX, clientY);
if (!Array.isArray(shape.points)) shape.points = [];
if (shape._lastAddTime == null) shape._lastAddTime = performance.now();
const pts = shape.points;
const last = pts[pts.length - 1];
const now = performance.now();
const dt = now - shape._lastAddTime;
if (!last) {
pts.push(p);
shape._lastAddTime = now;
return;
}
const dx = p.x - last.x;
const dy = p.y - last.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minStep * minStep || dt >= maxDtMs) {
pts.push(p);
shape._lastAddTime = now;
}
}
function undo() {
if (historyIndex <= 0) return;
historyIndex--;
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
core.redrawAll();
}
function redo() {
if (historyIndex >= history.length - 1) return;
historyIndex++;
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
core.redrawAll();
}
function commit(nextShapes) {
history.splice(historyIndex + 1);
history.push(structuredClone(nextShapes));
historyIndex++;
if (history.length > MAX_HISTORY) {
const overflow = history.length - MAX_HISTORY;
history.splice(0, overflow);
historyIndex -= overflow;
if (historyIndex < 0) historyIndex = 0;
}
const rebuilt = core.rebuildPathCaches(nextShapes);
core.setDoc({ ...core.doc, shapes: rebuilt, cellSize: core.cellSize });
core.redrawAll();
}
function snapDown(n, step) {
return Math.floor(n / step) * step;
}
function applySnappedCellSize() {
sizingRAF = 0;
const grid = core.cellSize;
if (!Number.isFinite(grid) || grid < 1) return;
const w = gridWrapEl.clientWidth;
const h = gridWrapEl.clientHeight;
const snappedW = snapDown(w, grid);
const snappedH = snapDown(h, grid);
// Only touch width-related CSS if width changed
const wChanged = snappedW !== lastApplied.w;
const hChanged = snappedH !== lastApplied.h;
if (!wChanged && !hChanged) return;
lastApplied = { w: snappedW, h: snappedH };
// critical: don't let observer see our own updates as layout input
ro.disconnect();
gridEl.style.width = `${snappedW}px`;
gridEl.style.height = `${snappedH}px`;
if (wChanged) {
root.style.setProperty('--grid-maxw', `${snappedW}px`);
}
ro.observe(gridWrapEl);
core.resizeAndSetupCanvas();
}
function scheduleSnappedCellSize() {
if (sizingRAF) return;
sizingRAF = requestAnimationFrame(applySnappedCellSize);
}
function applyCellSize(newSize) {
const n = Number(newSize);
if (!Number.isFinite(n) || n < 1) return;
core.setDoc({ ...core.doc, cellSize: n, shapes: core.shapes });
dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
dotSVGEl?.setAttribute('width', dotSize);
dotSVGEl?.setAttribute('height', dotSize);
setGrid();
scheduleSnappedCellSize();
}
function snapToGrid(x, y) {
const rect = gridEl.getBoundingClientRect();
const clampedX = Math.min(Math.max(x, rect.left), rect.right);
const clampedY = Math.min(Math.max(y, rect.top), rect.bottom);
const localX = clampedX - rect.left;
const localY = clampedY - rect.top;
const grid = core.cellSize;
const maxIx = Math.floor(rect.width / grid);
const maxIy = Math.floor(rect.height / grid);
const ix = Math.min(Math.max(Math.round(localX / grid), 0), maxIx);
const iy = Math.min(Math.max(Math.round(localY / grid), 0), maxIy);
const type = core.getActiveType();
let snapX = localX;
let snapY = localY;
if (type === 'fullGrid' || type === 'verticalGrid') {
snapX = Math.min(ix * grid, rect.width);
}
if (type === 'fullGrid' || type === 'horizontalGrid') {
snapY = Math.min(iy * grid, rect.height);
}
return {
ix,
iy,
x: snapX,
y: snapY,
localX,
localY
};
}
function normalizeRect(shape) {
const x1 = core.pxToGrid(shape.x1);
const y1 = core.pxToGrid(shape.y1);
const x2 = core.pxToGrid(shape.x2);
const y2 = core.pxToGrid(shape.y2);
return {
type: 'rect',
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
color: shape.color,
fill: shape.fill,
fillOpacity: core.clamp01(shape.fillOpacity, 1),
strokeOpacity: core.clamp01(shape.strokeOpacity, 1),
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12
};
}
function normalizeEllipse(shape) {
const r = normalizeRect(shape);
return { ...r, type: 'ellipse' };
}
function normalizeLine(shape) {
return {
type: 'line',
x1: core.pxToGrid(shape.x1),
y1: core.pxToGrid(shape.y1),
x2: core.pxToGrid(shape.x2),
y2: core.pxToGrid(shape.y2),
color: shape.color,
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
strokeOpacity: core.clamp01(shape.strokeOpacity)
};
}
function cancelStroke(e) {
const owns = (window.__gridPointerOwner === api) &&
(e ? window.__gridPointerId === e.pointerId : true);
if (!owns) return;
currentShape = null;
activePointerId = null;
window.__gridPointerOwner = null;
window.__gridPointerId = null;
core.redrawAll();
}
function onPointerUp(e) {
if (!currentShape) return;
// Only finalize if this pointer is the captured one (or we failed to capture, sigh)
if (gridEl.hasPointerCapture?.(e.pointerId)) {
gridEl.releasePointerCapture(e.pointerId);
}
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
currentShape.x2 = snapX;
currentShape.y2 = snapY;
let finalShape = null;
if (currentShape.tool === 'pen') {
const pts = currentShape.points;
if (pts.length >= 2) {
const coarse = [pts[0]];
const minStepPx = 0.75;
const minStep = minStepPx / core.cellSize;
for (let i = 1; i < pts.length; i++) {
if (dist2(pts[i], coarse[coarse.length - 1]) >= minStep * minStep) {
coarse.push(pts[i]);
}
}
if (coarse.length >= 2) {
const epsilon = Math.max(0.01, (currentShape.strokeWidth ?? 0.12) * 0.75);
const simplified = simplifyRDP(coarse, epsilon);
if (simplified.length >= 2) {
finalShape = {
type: 'path',
points: simplified,
color: currentShape.color || '#000000',
strokeWidth: core.isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
strokeOpacity: core.clamp01(currentShape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity)
};
}
}
}
} else if (currentShape.tool === 'line') {
const line = normalizeLine(currentShape);
if (line.x1 !== line.x2 || line.y1 !== line.y2) finalShape = line;
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
const rect = normalizeRect(currentShape);
if (rect.w > 0 && rect.h > 0) finalShape = rect;
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
const ellipse = normalizeEllipse(currentShape);
if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse;
}
if (finalShape) {
if (finalShape && ('_lastAddTime' in finalShape)) delete finalShape._lastAddTime;
commit([...core.shapes, finalShape]);
suppressNextClick = true;
setTimeout(() => { suppressNextClick = false; }, 0);
}
currentShape = null;
core.renderAllWithPreview(null);
}
gridEl.addEventListener('pointerup', finishPointer);
function setSelection(hit) {
if (!hit) {
selectedIndex = -1;
core.selectedShape = null;
core.redrawAll();
return;
}
selectedIndex = hit.index;
core.selectedShape = hit.shape;
core.redrawAll();
}
gridEl.addEventListener('click', (e) => {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
if (currentShape) return;
if (e.target.closest('[data-toolbar]')) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
if (hit) root.dispatchEvent(new CustomEvent('shape:click', { detail: hit }));
});
gridEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (currentShape) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
root.dispatchEvent(new CustomEvent('shape:contextmenu', {
detail: { hit, clientX: e.clientX, clientY: e.clientY }
}));
});
gridEl.addEventListener('dblclick', (e) => {
if (currentShape) return;
if (e.target.closest('[data-toolbar]')) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
if (hit) root.dispatchEvent(new CustomEvent('shape:dblclick', { detail: hit }));
});
root.querySelectorAll('input[data-tool]').forEach((input) => {
input.addEventListener('change', () => {
if (input.checked) {
localStorage.setItem(`${storageKey}:tool`, input.value);
}
});
});
root.querySelectorAll('input[data-gridtype]').forEach((input) => {
input.addEventListener('change', () => {
if (input.checked) {
localStorage.setItem(`${storageKey}:gridType`, input.value);
}
setGrid();
core.redrawAll();
});
});
cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
importButtonEl.addEventListener('click', () => importEl.click());
importEl.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = decode(JSON.parse(reader.result));
if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) {
cellSizeEl.value = data.cellSize;
applyCellSize(data.cellSize);
}
const loadedShapes = Array.isArray(data?.shapes) ? data.shapes : [];
const rebuilt = core.rebuildPathCaches(core.sanitizeShapes(loadedShapes));
core.setDoc({ version: Number(data?.version) || 1, cellSize: Number(data?.cellSize) || core.cellSize, shapes: rebuilt });
history.length = 0;
history.push(structuredClone(core.shapes));
historyIndex = 0;
core.redrawAll();
} catch {
toastMessage('Failed to load data from JSON file.', 'danger');
}
};
reader.readAsText(file);
});
exportEl.addEventListener('click', () => {
const payload = encode({ cellSize: core.cellSize, shapes: core.shapes, stripCaches: core.stripCaches, SHAPE_DEFAULTS });
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'grid-shapes.json';
a.click();
URL.revokeObjectURL(url);
});
clearEl.addEventListener('click', () => {
cellSizeEl.value = 25;
core.setDoc({ ...core.doc, cellSize: 25, shapes: [] });
history.length = 0;
history.push([]);
historyIndex = 0;
core.redrawAll();
});
colorEl.addEventListener('input', () => {
selectedColor = colorEl.value || '#000000';
const circle = dotSVGEl.querySelector('circle');
if (circle) {
circle.setAttribute('fill', selectedColor);
}
});
fillOpacityEl?.addEventListener('input', () => {
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
});
fillOpacityEl?.addEventListener('change', () => {
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('input', () => {
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('change', () => {
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
});
strokeWidthEl?.addEventListener('input', () => {
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
});
strokeWidthEl?.addEventListener('change', () => {
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
});
gridEl.addEventListener('pointercancel', (e) => cancelStroke(e));
gridEl.addEventListener('lostpointercapture', (e) => cancelStroke(e));
gridEl.addEventListener('pointermove', (e) => {
if (!core.ctx) return;
const rect = gridEl.getBoundingClientRect();
const inside = isInsideRect(e.clientX, e.clientY, rect);
const drawing = !!currentShape;
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
const tool = core.getActiveTool();
if (!drawing && !inside) {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
} else {
coordsEl.classList.remove('d-none');
if (core.getActiveType() !== 'noGrid' && tool !== 'pen') {
dotEl.classList.remove('d-none');
const wrapRect = gridWrapEl.getBoundingClientRect();
const offsetX = rect.left - wrapRect.left;
const offsetY = rect.top - wrapRect.top;
dotEl.style.left = `${offsetX + snapX}px`;
dotEl.style.top = `${offsetY + snapY}px`;
} else {
dotEl.classList.add('d-none');
}
}
if (core.getActiveType() == 'noGrid') {
coordsEl.innerText = `(px x=${Math.round(localX)} y=${Math.round(localY)})`;
} else {
coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}px))`;
}
if (!currentShape) return;
// PEN: mutate points and preview the same shape object
if (currentShape.tool === 'pen') {
const minStepPx = 0.75;
const minStep = minStepPx / core.cellSize;
penAddPoint(currentShape, e.clientX, e.clientY, minStep, 16);
// realtime instrumentation
coordsEl.innerText += ` | pts=${currentShape.points?.length ?? 0}`;
core.renderAllWithPreview(currentShape, false);
return;
}
// Other tools: build a normalized preview shape
let preview = null;
if (currentShape.tool === 'line') {
preview = normalizeLine({
x1: currentShape.x1,
y1: currentShape.y1,
x2: snapX,
y2: snapY,
color: currentShape.color,
strokeWidth: currentShape.strokeWidth,
strokeOpacity: currentShape.strokeOpacity
});
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY });
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
}
core.renderAllWithPreview(preview, currentShape.tool !== 'pen');
});
gridEl.addEventListener('pointerleave', (e) => {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
});
gridEl.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('[data-toolbar]')) return;
e.preventDefault();
activePointerId = e.pointerId;
window.__gridPointerOwner = api;
window.__gridPointerId = e.pointerId;
try {
gridEl.setPointerCapture(e.pointerId);
} catch {
// ignore: some browsers / scenarios won't allow capture
}
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
const tool = core.getActiveTool();
if (tool === 'line') {
currentShape = {
tool,
type: 'line',
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
strokeWidth: currentStrokeWidth,
strokeOpacity: currentStrokeOpacity
};
} else if (tool === 'outline' || tool === 'filled') {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: (tool === 'filled'),
fillOpacity: currentFillOpacity,
strokeOpacity: currentStrokeOpacity,
strokeWidth: currentStrokeWidth
};
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: (tool === 'filledEllipse'),
fillOpacity: currentFillOpacity,
strokeOpacity: currentStrokeOpacity,
strokeWidth: currentStrokeWidth
};
} else if (tool === 'pen') {
const p = core.pxToDocPoint(e.clientX, e.clientY);
currentShape = {
tool,
type: 'path',
points: [p],
color: selectedColor,
strokeWidth: currentStrokeWidth,
strokeOpacity: currentStrokeOpacity,
_lastAddTime: performance.now()
};
}
});
}

View file

@ -0,0 +1,136 @@
import { encode, decode } from './encode-decode.js';
import { createWidgetCore, DEFAULT_DOC } from "./widget-core.js";
import { initWidgetEditor } from "./widget-editor.js";
import { initWidgetViewer } from "./widget-viewer.js";
function readEmbeddedDoc(root, toastMessage) {
const el = root.querySelector('[data-grid-doc]');
if (!el) return null;
const raw = (el.textContent || '').trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return decode(parsed);
} catch (err) {
toastMessage?.(`Failed to parse embedded grid JSON: ${err?.message || err}`, 'danger');
return null;
}
}
export function initGridWidget(root, opts = {}) {
const mode = opts.mode || 'editor';
const storageKey = opts.storageKey ?? 'gridDoc';
const canvasEl = root.querySelector('[data-canvas]');
const gridEl = root.querySelector('[data-grid]');
const gridWrapEl = root.querySelector('[data-grid-wrap]');
if (!canvasEl || !gridEl || !gridWrapEl) {
throw new Error("Grid widget: missing required viewer elements.");
}
const toastMessage = opts.toastMessage || (() => { });
let initialDoc = opts.doc ?? null;
if (!initialDoc && mode !== 'editor') {
initialDoc = readEmbeddedDoc(root, toastMessage);
}
const core = createWidgetCore({
root,
mode,
storageKey,
gridEl,
canvasEl,
viewerOffset: opts.viewerOffset || { x: 0, y: 0 },
doc: initialDoc,
cellSize: opts.cellSize,
shapes: opts.shapes,
loadRaw() {
if (mode !== 'editor') return null;
return localStorage.getItem(storageKey);
},
saveRaw(_rawInternalDoc) {
if (mode !== 'editor') return;
const payload = encode({
cellSize: core.cellSize,
shapes: core.shapes,
stripCaches: core.stripCaches,
SHAPE_DEFAULTS: core.SHAPE_DEFAULTS
});
localStorage.setItem(storageKey, JSON.stringify(payload));
}
});
const env = { root, gridEl, gridWrapEl, toastMessage, storageKey };
if (mode === 'editor') {
const raw = localStorage.getItem(storageKey);
if (raw) {
try {
const decoded = decode(JSON.parse(raw));
core.setDoc(decoded);
} catch {
core.setDoc(DEFAULT_DOC);
}
} else {
const raw = root.dataset.doc;
if (raw) {
try {
const decoded = decode(JSON.parse(raw));
core.setDoc(decoded);
} catch {
core.setDoc(DEFAULT_DOC);
}
} else {
core.setDoc(DEFAULT_DOC);
}
}
} else {
const embedded = initialDoc ?? readEmbeddedDoc(root, toastMessage);
if (embedded) core.setDoc(embedded);
}
let editorApi = null;
if (mode === 'editor') {
editorApi = initWidgetEditor(core, env);
}
let viewerApi = null;
if (mode !== 'editor') {
viewerApi = initWidgetViewer(core, { core, gridEl, gridWrapEl });
}
const api = {
core,
mode,
redraw() { core.redrawAll(); },
destroy() { editorApi?.destroy?.(); },
get doc() { return core.doc; },
get shapes() { return core.shapes; },
get cellSize() { return core.cellSize; },
};
if (editorApi) {
api.handleKeyDown = editorApi.handleKeyDown;
api.handleGlobalPointerUp = editorApi.handleGlobalPointerUp;
api.cancelStroke = editorApi.cancelStroke;
}
if (viewerApi) {
api.setDoc = viewerApi.setDoc;
api.redraw = viewerApi.redraw;
api.destroy = viewerApi.destroy;
api.decode = viewerApi.decode;
}
return api;
}

View file

@ -0,0 +1,66 @@
import { decode } from "./encode-decode.js";
export function initWidgetViewer(core, env) {
const { mode, gridEl, gridWrapEl } = env;
if (mode === 'editor') return null;
let resizeRAF = 0;
function applyViewerBoundsSizing() {
const b = core.getShapesBounds(core.shapes);
const padCells = 0.5;
const wCells = b ? (b.maxX - b.minX + padCells * 2) : 10;
const hCells = b ? (b.maxY - b.minY + padCells * 2) : 10;
const wPx = Math.max(1, Math.ceil(wCells * core.cellSize));
const hPx = Math.max(1, Math.ceil(hCells * core.cellSize));
gridEl.style.width = `${wPx}px`;
gridEl.style.height = `${hPx}px`;
gridWrapEl.style.width = `${wPx}px`;
gridWrapEl.style.height = `${hPx}px`;
if (b) {
core.viewerOffset = {
x: (-b.minX + padCells) * core.cellSize,
y: (-b.minY + padCells) * core.cellSize
};
} else {
core.viewerOffset = { x: 0, y: 0 };
}
}
const scheduleResize = () => {
if (resizeRAF) return;
resizeRAF = requestAnimationFrame(() => {
resizeRAF = 0;
applyViewerBoundsSizing();
core.resizeAndSetupCanvas();
});
};
const ro = new ResizeObserver(scheduleResize);
ro.observe(gridWrapEl);
window.addEventListener('resize', scheduleResize, { passive: true });
requestAnimationFrame(scheduleResize);
function setDoc(nextDoc) {
core.setDoc(nextDoc);
applyViewerBoundsSizing();
core.resizeAndSetupCanvas();
}
return {
setDoc,
redraw: () => core.redrawAll(),
destroy() {
ro.disconnect();
window.removeEventListener('resize', scheduleResize);
},
decode
};
}

View file

@ -0,0 +1,105 @@
const ImageDisplay = globalThis.ImageDisplay ?? (globalThis.ImageDisplay = {});
ImageDisplay.utilities = {
fileInput: document.getElementById('image'),
image: document.getElementById('imageDisplay'),
captionInput: document.getElementById('caption'),
removeButton: document.getElementById('remove-inventory-image'),
imageIdInput: document.getElementById('image_id'),
// set when user selects a new file
_dirty: false,
_removed: false,
onAddButtonClick() {
this.fileInput.click();
},
onRemoveButtonClick() {
// Clear preview back to placeholder
this.image.src = this.image.dataset.placeholder || this.image.src;
this.fileInput.value = '';
this._dirty = false;
this._removed = true;
this.imageIdInput.value = '';
this.removeButton.classList.add('d-none');
},
onFileChange() {
const [file] = this.fileInput.files;
if (!file) {
toastMessage('No file selected!', 'danger');
return;
}
if (!file.type.startsWith("image")) {
toastMessage('Unsupported file type!', 'danger')
this.fileInput.value = '';
return;
}
const url = URL.createObjectURL(file);
this.image.src = url;
if (this.removeButton) {
this.removeButton.classList.remove('d-none');
}
this._dirty = true;
this._removed = false;
},
async uploadIfChanged() {
// If no changes to image, do nothing
if (!this._dirty && !this._removed) return null;
// Removed but not replaced: tell backend to clear image_id
if (this._removed) {
return { remove: true };
}
const [file] = this.fileInput.files;
if (!file) return null;
if(!window.IMAGE_UPLOAD_URL) {
toastMessage('IMAGE_UPLOAD_URL not set', 'danger');
return null;
}
const fd = new FormData();
fd.append('image', file);
if (this.captionInput) {
fd.append('caption', this.captionInput.value || '');
}
if (window.IMAGE_OWNER_MODEL) {
fd.append('model', window.IMAGE_OWNER_MODEL);
}
if (this.imageIdInput && this.imageIdInput.value) {
fd.append('image_id', this.imageIdInput.value);
}
const res = await fetch(window.IMAGE_UPLOAD_URL, {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
toastMessage(data.error || 'Image upload failed.', 'danger');
throw new Error(data.error || 'Image upload failed.');
}
// Update local state
this.imageIdInput.value = data.id;
this._dirty = false;
this._removed = false;
return {
id: data.id,
filename: data.filename,
url: data.url,
};
},
};

View file

@ -0,0 +1,36 @@
const MarkDown = {
parseOptions: { gfm: true, breaks: false },
sanitizeOptions: { ADD_ATTR: ['target', 'rel'] },
toHTML(md) {
const raw = marked.parse(md || "", this.parseOptions);
return DOMPurify.sanitize(raw, this.sanitizeOptions);
},
enhance(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
for (const q of root.querySelectorAll('blockquote')) {
q.classList.add('blockquote', 'border-start', 'border-5', 'border-success', 'mt-3', 'ps-3');
}
for (const l of root.querySelectorAll('ul')) {
l.classList.add('list-group');
}
for (const l of root.querySelectorAll('li')) {
l.classList.add('list-group-item');
}
},
renderInto(el, md) {
if (!el) return;
el.innerHTML = this.toHTML(md);
this.enhance(el);
}
};

View file

@ -0,0 +1,7 @@
function readJSONScript(id, fallback = "") {
const el = document.getElementById(id);
if (!el) return fallback;
const txt = el.textContent?.trim();
if (!txt) return fallback;
try { return JSON.parse(txt); } catch { return fallback; }
}

View file

@ -40,6 +40,7 @@
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a> <a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li> <li><a class="dropdown-item" href="{{ url_for('reports.summary') }}">Inventory Summary</a></li>
<li><a class="dropdown-item" href="{{ url_for('reports.problems') }}">Problems</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>

View file

@ -0,0 +1,266 @@
{% macro drawWidget(uid) %}
<div class="grid-widget" data-grid-widget data-mode="editor" data-storage-key="gridDoc:{{ uid }}">
<div data-toolbar
class="btn-toolbar bg-light border border-bottom-0 rounded-bottom-0 border-secondary-subtle rounded p-1 align-items-center flex-nowrap overflow-auto toolbar">
<div class="toolbar-row toolbar-row--primary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" value="pen" name="tool-{{ uid }}" id="tool-pen-{{ uid }}"
data-tool checked>
<label for="tool-pen-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-pen" viewBox="0 0 16 16">
<path
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001m-.644.766a.5.5 0 0 0-.707 0L1.95 11.756l-.764 3.057 3.057-.764L14.44 3.854a.5.5 0 0 0 0-.708z" />
</svg>
</label>
<input type="radio" class="btn-check" value="line" name="tool-{{ uid }}" id="tool-line-{{ uid }}"
data-tool>
<label for="tool-line-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"
aria-hidden="true">
<path d="M4 12 L12 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="4" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
</svg>
</label>
<input type="radio" class="btn-check" value="outline" name="tool-{{ uid }}"
id="tool-outline-{{ uid }}" data-tool>
<label for="tool-outline-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square" viewBox="0 0 16 16">
<path
d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
</svg>
</label>
<input type="radio" class="btn-check" value="filled" name="tool-{{ uid }}"
id="tool-filled-{{ uid }}" data-tool>
<label for="tool-filled-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square-fill" viewBox="0 0 16 16">
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2z" />
</svg>
</label>
<input type="radio" class="btn-check" value="outlineEllipse" name="tool-{{ uid }}"
id="tool-outline-ellipse-{{ uid }}" data-tool>
<label for="tool-outline-ellipse-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
</svg>
</label>
<input type="radio" class="btn-check" value="filledEllipse" name="tool-{{ uid }}"
id="tool-filled-ellipse-{{ uid }}" data-tool>
<label for="tool-filled-ellipse-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle-fill" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="8" />
</svg>
</label>
</div>
<input type="color" class="form-control form-control-sm form-control-color" data-color>
</div>
<div class="toolbar-group">
<div class="btn-group">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
data-export>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-download" viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z" />
</svg>
</button>
<input type="file" data-import accept="application/json" class="d-none">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
data-import-button>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-upload" viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z" />
</svg>
</button>
<button type="button"
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center"
data-clear>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-x-lg" viewBox="0 0 16 16">
<path
d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z" />
</svg>
</button>
</div>
</div>
</div>
<div class="toolbar-row toolbar-row--secondary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="noGrid"
id="type-no-grid-{{ uid }}" data-gridtype checked>
<label for="type-no-grid-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border" viewBox="0 0 16 16">
<path
d="M0 0h.969v.5H1v.469H.969V1H.5V.969H0zm2.844 1h-.938V0h.938zm1.875 0H3.78V0h.938v1zm1.875 0h-.938V0h.938zm.937 0V.969H7.5V.5h.031V0h.938v.5H8.5v.469h-.031V1zm2.813 0h-.938V0h.938zm1.875 0h-.938V0h.938zm1.875 0h-.938V0h.938zM15.5 1h-.469V.969H15V.5h.031V0H16v.969h-.5zM1 1.906v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM1 3.78v.938H0V3.78zm6.5.938V3.78h1v.938zm7.5 0V3.78h1v.938zM1 5.656v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM.969 8.5H.5v-.031H0V7.53h.5V7.5h.469v.031H1v.938H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm1.875-.031V8.5H7.53v-.031H7.5V7.53h.031V7.5h.938v.031H8.5v.938zm1.875.031h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.406 0h-.469v-.031H15V7.53h.031V7.5h.469v.031h.5v.938h-.5zM0 10.344v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 12.22v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 14.094v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM.969 16H0v-.969h.5V15h.469v.031H1v.469H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm.937 0v-.5H7.5v-.469h.031V15h.938v.031H8.5v.469h-.031v.5zm2.813 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm.937 0v-.5H15v-.469h.031V15h.469v.031h.5V16z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="horizontalGrid"
id="type-horizontal-{{ uid }}" data-gridtype>
<label for="type-horizontal-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-center" viewBox="0 0 16 16">
<path
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM7.531.969V1h.938V.969H8.5V.5h-.031V0H7.53v.5H7.5v.469zM9.406 1h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM1 4.719V3.78H0v.938h1zm6.5-.938v.938h1V3.78h-1zm7.5 0v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM0 8.5v-1h16v1zm0 .906v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm1.875-.5v.5h.938v-.5H8.5v-.469h-.031V15H7.53v.031H7.5v.469zm1.875.5h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="verticalGrid"
id="type-vertical-{{ uid }}" data-gridtype>
<label for="type-vertical-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-middle" viewBox="0 0 16 16">
<path
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM8.5 16h-1V0h1zm.906-15h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm14-.938v.938h1v-.938zM1 4.719V3.78H0v.938h1zm14-.938v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm14-.938v.938h1v-.938zM.5 8.5h.469v-.031H1V7.53H.969V7.5H.5v.031H0v.938h.5zm1.406 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.469v-.031h.5V7.53h-.5V7.5h-.469v.031H15v.938h.031zM0 9.406v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="fullGrid"
id="type-full-{{ uid }}" data-gridtype>
<label for="type-full-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-all" viewBox="0 0 16 16">
<path d="M0 0h16v16H0zm1 1v6.5h6.5V1zm7.5 0v6.5H15V1zM15 8.5H8.5V15H15zM7.5 15V8.5H1V15z" />
</svg>
</label>
</div>
</div>
<div class="toolbar-group">
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grid-3x3-gap" viewBox="0 0 16 16">
<path
d="M4 2v2H2V2zm1 12v-2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m5 10v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1M9 2v2H7V2zm5 0v2h-2V2zM4 7v2H2V7zm5 0v2H7V7zm5 0h-2v2h2zM4 12v2H2v-2zm5 0v2H7v-2zm5 0v2h-2v-2zM12 1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm-1 6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
<small data-cell-size-val>25px</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Cell Size</div>
<input type="range" min="1" max="100" step="1" value="25" data-cell-size
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-droplet-fill" viewBox="0 0 16 16">
<path
d="M8 16a6 6 0 0 0 6-6c0-1.655-1.122-2.904-2.432-4.362C10.254 4.176 8.75 2.503 8 0c0 0-6 5.686-6 10a6 6 0 0 0 6 6M6.646 4.646l.708.708c-.29.29-1.128 1.311-1.907 2.87l-.894-.448c.82-1.641 1.717-2.753 2.093-3.13" />
</svg>
<small data-fill-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Fill Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-fill-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-dash-square-dotted" viewBox="0 0 16 16">
<path
d="M2.5 0q-.25 0-.487.048l.194.98A1.5 1.5 0 0 1 2.5 1h.458V0zm2.292 0h-.917v1h.917zm1.833 0h-.917v1h.917zm1.833 0h-.916v1h.916zm1.834 0h-.917v1h.917zm1.833 0h-.917v1h.917zM13.5 0h-.458v1h.458q.151 0 .293.029l.194-.981A2.5 2.5 0 0 0 13.5 0m2.079 1.11a2.5 2.5 0 0 0-.69-.689l-.556.831q.248.167.415.415l.83-.556zM1.11.421a2.5 2.5 0 0 0-.689.69l.831.556c.11-.164.251-.305.415-.415zM16 2.5q0-.25-.048-.487l-.98.194q.027.141.028.293v.458h1zM.048 2.013A2.5 2.5 0 0 0 0 2.5v.458h1V2.5q0-.151.029-.293zM0 3.875v.917h1v-.917zm16 .917v-.917h-1v.917zM0 5.708v.917h1v-.917zm16 .917v-.917h-1v.917zM0 7.542v.916h1v-.916zm15 .916h1v-.916h-1zM0 9.375v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .916v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .917v.458q0 .25.048.487l.98-.194A1.5 1.5 0 0 1 1 13.5v-.458zm16 .458v-.458h-1v.458q0 .151-.029.293l.981.194Q16 13.75 16 13.5M.421 14.89c.183.272.417.506.69.689l.556-.831a1.5 1.5 0 0 1-.415-.415zm14.469.689c.272-.183.506-.417.689-.69l-.831-.556c-.11.164-.251.305-.415.415l.556.83zm-12.877.373Q2.25 16 2.5 16h.458v-1H2.5q-.151 0-.293-.029zM13.5 16q.25 0 .487-.048l-.194-.98A1.5 1.5 0 0 1 13.5 15h-.458v1zm-9.625 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zm1.834 0h.916v-1h-.916zm1.833 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
</svg>
<small data-stroke-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-stroke-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="currentColor" class="me-1" aria-hidden="true">
<rect x="2" y="3" width="12" height="1" rx=".5"></rect>
<rect x="2" y="7" width="12" height="2" rx="1"></rect>
<rect x="2" y="12" width="12" height="3" rx="1.5"></rect>
</svg>
<small data-stroke-width-val>12%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Width</div>
<input type="range" min="0" max="1" step="0.01" value="0.12" data-stroke-width
class="form-range w-100">
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<div class="position-relative overflow-hidden grid" data-grid>
<div class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></div>
<canvas class="position-absolute w-100 h-100" data-canvas></canvas>
</div>
</div>
</div>
{% endmacro %}
{% macro viewWidget(uid, json) %}
<span class="grid-widget" data-grid-widget data-mode="viewer" data-storage-key="gridDoc:{{ uid }}">
<span class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<span class="position-relative overflow-hidden grid" data-grid>
<span
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></span>
<canvas class="position-absolute" data-canvas></canvas>
</span>
</span>
<script type="application/json" data-grid-doc>
{{ json | safe }}
</script>
</span>
{% endmacro %}

View file

@ -1,25 +1,9 @@
{# show label unless hidden/custom #} <!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}"
{% if label_attrs %}{% for k,v in label_attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label>
{% endif %}
{% if field_type == 'select' %} {% if field_type == 'select' %}
<select name="{{ field_name }}" id="{{ field_name }}" {#
{% if attrs %}{% for k,v in attrs.items() %} <select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
{% endfor %}{% endif %}
{%- if not options %} disabled{% endif %}>
{% if options %} {% if options %}
<option value="">-- Select --</option> <option value="">-- Select --</option>
{% for opt in options %} {% for opt in options %}
@ -31,53 +15,89 @@
<option value="">-- No selection available --</option> <option value="">-- No selection available --</option>
{% endif %} {% endif %}
</select> </select>
#}
{% if options %}
{% if value %}
{% set opts = options or [] %}
{% set selected = opts | selectattr('value', 'equalto', value) | list %}
{% if not selected %}
{% set selected = opts | selectattr('value', 'equalto', value|string) | list %}
{% endif %}
{% set sel = selected[0] if selected else none %}
{% if sel %}
{% set sel_label = sel['label'] %}
{% elif value_label %}
{% set sel_label = value_label %}
{% else %}
{% set sel_label = "-- Select --" %}
{% endif %}
{% else %}
{% set sel_label = "-- Select --" %}
{% endif %}
<input type="button" class="form-control btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" data-field="{{ field_name }}" value="{{ sel_label }}">
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
<input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0"
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
{% for opt in options %}
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')"
id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
{% endfor %}
</div>
{% else %}
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No
selection available --</button>
{% endif %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else '' }}">
{% elif field_type == 'textarea' %} {% elif field_type == 'textarea' %}
<textarea name="{{ field_name }}" id="{{ field_name }}" <textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
{% if attrs %}{% for k,v in attrs.items() %} sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{% elif field_type == 'checkbox' %} {% elif field_type == 'checkbox' %}
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" <input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
{% if value %}checked{% endif %} attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
{% if attrs %}{% for k,v in attrs.items() %} %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == 'hidden' %} {% elif field_type == 'hidden' %}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"> <input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}">
{% elif field_type == 'display' %} {% elif field_type == 'display' %}
<div {% if attrs %}{% for k,v in attrs.items() %} <div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% elif field_type == "date" %} {% elif field_type == "date" %}
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "time" %} {% elif field_type == "time" %}
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "datetime" %} {% elif field_type == "datetime" %}
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
{% if attrs %}{% for k,v in attrs.items() %} attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %} %}>
{% endfor %}{% endif %}>
{% else %} {% else %}
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" <input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
{% if attrs %}{% for k,v in attrs.items() %} for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% endif %} {% endif %}
{% if help %} {% if help %}
<div class="form-text">{{ help }}</div> <div class="form-text">{{ help }}</div>
{% endif %} {% endif %}
{% if field_type != 'hidden' and field_label %}
<label for="{{ field_name }}" {% if label_attrs %}{% for k,v in label_attrs.items() %} {{k}}{% if v is not sameas true
%}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% if link_href %}
<a href="{{ link_href }}" class="link-success link-underline link-underline-opacity-0 fw-semibold">
{% endif %}
{{ field_label }}
{% if link_href %}
</a>
{% endif %}
</label>
{% endif %}

View file

@ -1,5 +1,6 @@
<!-- TABLE {{ kwargs['object_class'] if kwargs else '(NO MODEL ASSOCIATED)' }} -->
<div class="table-responsive" style="max-height: 80vh;"> <div class="table-responsive" style="max-height: 80vh;">
<table class="table table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto"> <table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<thead> <thead>
<tr> <tr>
{% for col in columns %} {% for col in columns %}

View file

@ -1,8 +1,18 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/image_display.css') }}">
{% endblock %}
{% block main %} {% block main %}
<div class="container mt-5"> <div class="container mt-5">
{{ form | safe }} {{ form | safe }}
</div> </div>
{% endblock %} {% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/image_display.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
{% endblock %}

View file

@ -1,11 +1,101 @@
<div class="btn-group"> <div class="btn-group">
<button type="submit" class="btn btn-primary" id="submit">Save</button> <button type="submit" class="btn btn-outline-primary" id="submit">Save</button>
<button type="button" class="btn btn-outline-primary" onclick="location.href='{{ url_for("entry.entry_new", model=(field['attrs']['data-model'])) }}'">New</button> <button type="button" class="btn btn-outline-success"
onclick="location.href='{{ url_for('entry.entry_new', model=field['attrs']['data-model']) }}'"
id="new">New</button>
<button type="button" class="btn btn-outline-danger" id="delete" onclick="deleteEntry()">Delete</button>
</div> </div>
<script> <script>
window.newDrafts = window.newDrafts || []; window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || []; window.deletedIds = window.deletedIds || [];
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
// Build delete URL only if we have an id, or leave it empty string
{% set model = field['attrs']['data-model'] %}
{% set obj_id = field['template_ctx']['values'].get('id') %}
{% set delete_url = obj_id and url_for('crudkit.' ~model ~ '.rest_delete', obj_id = obj_id) %}
const DELETE_URL = {{ (delete_url or '') | tojson }};
// Form metadata
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
if (!hasId) {
const delBtn = document.getElementById('delete');
delBtn.disabled = true;
delBtn.classList.add('disabled');
}
async function deleteEntry() {
const delBtn = document.getElementById('delete');
if (!DELETE_URL) return;
if (!window.confirm('Delete this entry?')) return;
delBtn.disabled = true;
try {
const res = await fetch(DELETE_URL, {
method: 'DELETE',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
});
let data = null;
if (res.status !== 204) {
const text = await res.text();
if (text) {
const ct = res.headers.get('content-type') || '';
data = ct.includes('application/json') ? JSON.parse(text) : { message: text };
}
}
if (!res.ok) {
const msg = (data && (data.detail || data.error || data.message)) ||
`Request failed with ${res.status} ${res.statusText}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
queueToast((data && (data.detail || data.message)) || 'Item deleted.', 'success');
location.assign(LIST_URL);
} catch (err) {
if (err?.name === 'AbortError') {
toastMessage('Network timeout while deleting item.', 'danger');
} else if (err?.status === 409) {
toastMessage(`Delete blocked: ${err.message}`, 'warning');
} else {
toastMessage(`Network error: ${String(err?.message || err)}`, 'danger');
}
} finally {
delBtn.disabled = false;
}
}
{% if field['attrs']['data-model'] == 'worklog' %}
function collectExistingUpdateIds() {
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
function collectEditedUpdates() {
const updates = [];
const deleted = new Set(collectDeletedIds());
for (const id of collectExistingUpdateIds()) {
if (deleted.has(id)) continue;
updates.push({ id, content: getMarkdown(id) });
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
{% endif %}
function formToJson(form) { function formToJson(form) {
const fd = new FormData(form); const fd = new FormData(form);
@ -36,45 +126,38 @@
return out; return out;
} }
function collectExistingUpdateIds() { // URLs for create/update
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
function collectEditedUpdates() {
const updates = [];
const deleted = new Set(collectDeletedIds());
for (const id of collectExistingUpdateIds()) {
if(deleted.has(id)) continue; // skip ones marked for deletion
updates.push({ id, content: getMarkdown(id) });
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
// much simpler, and correct
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
// Never call url_for for update on the "new" page.
// Create URL is fine to build server-side:
const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }}; const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }};
// Update URL is assembled on the client to avoid BuildError on "new":
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null; const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
formEl.addEventListener("submit", async e => { formEl.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const submitBtn = document.getElementById('submit');
submitBtn.disabled = true;
try {
const json = formToJson(formEl); const json = formToJson(formEl);
if (model === 'inventory') {
// the file input 'image' must NOT go into the JSON at all
delete json.image;
}
// Handle image for inventory
if (model === 'inventory' && globalThis.ImageDisplay?.utilities) {
const imgResult = await ImageDisplay.utilities.uploadIfChanged();
if (imgResult?.remove) {
json.image_id = null;
} else if (imgResult && imgResult.id) {
json.image_id = imgResult.id; // ✅ this, and ONLY this
}
}
if (model === 'inventory' && typeof getMarkdown === 'function') { if (model === 'inventory' && typeof getMarkdown === 'function') {
const md = getMarkdown(); const md = getMarkdown();
json.notes = (typeof md === 'string') ? getMarkdown().trim() : ''; json.notes = (typeof md === 'string') ? md.trim() : '';
} else if (model === 'worklog') { } else if (model === 'worklog') {
json.updates = collectEditedUpdates(); json.updates = collectEditedUpdates();
json.delete_update_ids = collectDeletedIds(); json.delete_update_ids = collectDeletedIds();
@ -84,38 +167,43 @@
const url = hasId ? updateUrl : createUrl; const url = hasId ? updateUrl : createUrl;
try { console.log('Submitting JSON:', json);
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json), body: JSON.stringify(json),
credentials: 'same-origin'
}); });
const reply = await res.json(); const reply = await res.json();
if (reply.status === 'success') { if (reply.status === 'success') {
if (!hasId && reply.id) {
window.queueToast('Created successfully.', 'success');
window.newDrafts = []; window.newDrafts = [];
window.deletedIds = []; window.deletedIds = [];
window.location.assign(`/entry/${model}/${reply.id}`);
if (!hasId && reply.id) {
queueToast('Created successfully.', 'success');
location.assign(`/entry/${model}/${reply.id}`);
return; return;
} else { }
window.queueToast('Updated successfully.', 'success');
queueToast('Updated successfully.', 'success');
if (model === 'worklog') { if (model === 'worklog') {
for (const id of collectDeletedIds()) { for (const id of collectDeletedIds()) {
const li = document.getElementById(`note-${id}`); document.getElementById(`note-${id}`)?.remove();
if (li) li.remove();
} }
} }
window.newDrafts = []; location.replace(location.href);
window.deletedIds = []; } else {
window.location.replace(window.location.href); toastMessage(reply.message || 'Server reported failure.', 'danger');
return;
}
} }
} catch (err) { } catch (err) {
toastMessage(`Network error: ${String(err)}`, 'danger'); toastMessage(`Network error: ${String(err)}`, 'danger');
} finally {
submitBtn.disabled = false;
} }
}); });
</script> </script>

View file

@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}Internal Server Error{% endblock %}
{% block main %}
<h1 class="display-1 text-center">Internal Server Error</h1>
<div class="alert alert-danger text-center">This service has encountered an error. This error has been logged. Please try again later.</div>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}{{ code }} {{ name }}{% endblock %}
{% block main %}
<h1 class="display-1 text-center">{{ code }} - {{ name }}</h1>
<div class="alert alert-danger text-center">{{ description }}</div>
{% endblock %}

View file

@ -1,6 +1,43 @@
{% set model = field['attrs']['data-model'] %}
{% set image_id = field['template_ctx']['values'].get('image_id') %}
{% set buttons %}
<div class="btn-group position-absolute end-0 top-0 mt-2 me-2 border image-buttons">
<button type="button" class="btn btn-light" id="add-inventory-image"
onclick="ImageDisplay.utilities.onAddButtonClick();"><svg xmlns="http://www.w3.org/2000/svg" width="16"
height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2" />
</svg></button>
<button type="button" class="btn btn-danger{% if not value %} d-none{% endif %}" id="remove-inventory-image"
onclick="ImageDisplay.utilities.onRemoveButtonClick();">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash"
viewBox="0 0 16 16">
<path
d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z" />
<path
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z" />
</svg>
</button>
</div>
{% endset %}
{{ buttons }}
{% if value %} {% if value %}
<img src="{{ url_for('static', filename=field['value_label']) }}" alt="{{ value }}" {% if field['attrs'] %}{% for k,v in <img src="{{ url_for('static', filename=field['value_label']) }}" alt="{{ value }}" {% if field['attrs'] %}{% for k,v in
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}> field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
{% else %} {% else %}
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100"> <img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100"
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
{% endif %} {% endif %}
<input type="text" class="form-control" id="caption" name="caption"
value="{{ field['template_ctx']['values']['image.caption'] if value else '' }}">
<input type="hidden" id="image_id" name="image_id" value="{{ image_id if image_id is not none else '' }}">
<input type="file" class="d-none" name="image" id="image" accept="image/*"
onchange="ImageDisplay.utilities.onFileChange();">
<script>
// URL for image upload
window.IMAGE_UPLOAD_URL = {{ url_for('image.upload_image') | tojson }};
window.IMAGE_OWNER_MODEL = {{ model | tojson }};
</script>

View file

@ -5,9 +5,44 @@
<p class="lead text-center">Find out about all of your assets.</p> <p class="lead text-center">Find out about all of your assets.</p>
<div class="row mx-5"> <div class="row mx-5">
<div class="col pivot-cell ms-5"> <div class="col">
<p class="display-6 text-center">Active Worklogs</p> <p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }} {{ logs | safe }}
</div> </div>
<div class="col">
<p class="display-6 text-center">Supply Status</p>
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped table-hover">
{% if not needed_inventory.empty %}
<thead>
<tr>
<th>Device</th>
<th>Target</th>
<th>On Hand</th>
<th>Needed</th>
</tr>
</thead>
<tbody>
{% for row in needed_inventory.itertuples() %}
<tr class="{{ 'table-warning' if row.needed else '' }}"
onclick="location.href='{{ url_for('listing.show_list', model='inventory', device_type_id__eq=row.id) }}&condition.category=Available'"
style="cursor: pointer;">
<td>{{ row.description }}</td>
<td>{{ row.target }}</td>
<td>{{ row.actual }}</td>
<td class="{{ 'fw-bold' if row.needed else '' }}">{{ row.needed }}</td>
</tr>
{% endfor %}
</tbody>
{% else %}
<thead>
<tr>
<th colspan="4" class="text-center">No data.</th>
</tr>
</thead>
{% endif %}
</table>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -28,40 +28,30 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
renderView(getMarkdown()); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
}); });
function getMarkdown() { // used by entry_buttons submit
const el = document.getElementById('noteContent'); window.getMarkdown = function () {
return el ? (JSON.parse(el.textContent) || "") : ""; return readJSONScript('noteContent', "");
} };
function setMarkdown(md) { function setMarkdown(md) {
const el = document.getElementById('noteContent'); const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? ""); if (el) el.textContent = JSON.stringify(md ?? "");
} }
function renderView(md) {
const container = document.getElementById('editContainer');
if (!container) return;
const html = marked.parse(md || "", {gfm: true});
container.innerHTML = DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] });
for (const a of container.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
for (const t of container.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
function changeMode() { function changeMode() {
const container = document.getElementById('editContainer'); const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch'); const toggle = document.getElementById('editSwitch');
if (!toggle.checked) return renderView(getMarkdown()); if (!toggle.checked) {
MarkDown.renderInto(container, getMarkdown());
return;
}
const current = getMarkdown(); const current = getMarkdown();
container.innerHTML = ` container.innerHTML = `
@ -82,13 +72,13 @@
const ta = document.getElementById('editor'); const ta = document.getElementById('editor');
const value = ta ? ta.value : ""; const value = ta ? ta.value : "";
setMarkdown(value); setMarkdown(value);
renderView(value); MarkDown.renderInto(document.getElementById('editContainer'), value);
document.getElementById('editSwitch').checked = false; document.getElementById('editSwitch').checked = false;
} }
function cancelEdit() { function cancelEdit() {
document.getElementById('editSwitch').checked = false; document.getElementById('editSwitch').checked = false;
renderView(getMarkdown()); MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
} }
function togglePreview() { function togglePreview() {
@ -96,13 +86,13 @@
const preview = document.getElementById('preview'); const preview = document.getElementById('preview');
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : ""); MarkDown.renderInto(preview, ta ? ta.value : "");
preview.innerHTML = DOMPurify.sanitize(html);
} }
} }
function autoGrow(ta) { function autoGrow(ta) {
if (!ta) return; if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px'; ta.style.height = ta.scrollHeight + 'px';
} }

View file

@ -6,11 +6,14 @@ Inventory Manager - {{ model|title }} Listing
{% block main %} {% block main %}
<div class="mx-5"> <div class="mx-5">
<h1 class="display-4 text-center mt-2">{{ model|title }} Listing</h1> <div class="d-flex justify-content-between">
<div class="btn-group"> <div class="btn-group h-50 align-self-end">
<button type="button" class="btn btn-primary mb-3" <button type="button" class="btn btn-primary mb-2"
onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button> onclick="location.href='{{ url_for('entry.entry_new', model=model) }}'">New</button>
</div> </div>
<h1 class="display-6 text-center">{{ model|title }} Listing</h1>
<div></div>
</div>
{{ table | safe }} {{ table | safe }}

View file

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block main %}
<h1 class="display-4 mb-3 text-center">Records With Problems</h1>
<div class="container">
<p>Equipment Without Active Owner</p>
{{ orphans | safe }}
<p>Duplicate Inventory Entries</p>
<div class="table-responsive">
<table class="table table-sm table-info table-bordered table-striped table-hover">
<thead>
<tr>
{% for col in duplicate_columns %}
<th>
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for record in duplicates %}
<tr style="cursor: pointer;"
onclick="location.href='{{ url_for('entry.entry', model='inventory', id=record['id']) }}'">
{% for cell in record.values() %}
<td>
{{ cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -5,17 +5,76 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
{% endblock %} {% endblock %}
{% block style %}
.dt-target {
width: 6ch;
-moz-appearance: textfield;
appearance: textfield;
}
/* keep the row highlight, but keep the input looking normal */
.dt-option.selected .dt-target {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
}
/* nuke the blue focus ring/border inside selected rows */
.dt-option .dt-target:focus {
border-color: var(--bs-border-color);
box-shadow: none;
outline: 0;
}
.dt-target::-webkit-outer-spin-button,
.dt-target::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.dt-option {
cursor: default;
}
/* Selection styling for the row */
@supports (background-color: AccentColor) {
.dt-option.selected { background-color: AccentColor; }
.dt-option.selected .dt-label { color: AccentColorText; }
/* Hard force the input to be opaque and not inherit weirdness */
.dt-option .dt-target,
.dt-option .dt-target:focus {
background-color: Field !important; /* system input bg */
color: FieldText !important; /* system input text */
box-shadow: none; /* not the halo issue, but be thorough */
border-color: var(--bs-border-color); /* keep Bootstrap-ish border */
}
}
@supports not (background-color: AccentColor) {
.dt-option.selected { background-color: var(--bs-list-group-active-bg, #0d6efd); }
.dt-option.selected .dt-label { color: var(--bs-list-group-active-color, #fff); }
.dt-option .dt-target,
.dt-option .dt-target:focus {
background-color: var(--bs-body-bg) !important;
color: var(--bs-body-color) !important;
box-shadow: none;
border-color: var(--bs-border-color);
}
}
{% endblock %}
{% block main %} {% block main %}
<form id="settings_form" method="post"> <form id="settings_form" method="post">
<div class="container"> <div class="container">
<ul class="nav nav-pills nav-fill"> <ul class="nav nav-pills nav-fill">
<li class="nav-item"> <li class="nav-item">
<button type="button" class="nav-link active" id="device-tab" <button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button> data-bs-target="#device-tab-pane">Devices</button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button type="button" class="nav-link" id="location-tab" <button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button> data-bs-target="#location-tab-pane">Locations</button>
</li> </li>
</ul> </ul>
@ -24,12 +83,51 @@
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0"> <div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="brand" class="form-label">Brand</label> <label for="brand" class="form-label">Brands</label>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }} {{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
</div> </div>
<div class="col"> <div class="col">
<label for="devicetype" class="form-label">Device Type</label> <label for="devicetype" class="form-label">Device Types</label>
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }} {# { combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id',
'description') } #}
{# Going to specialize the combobox widget here. #}
<div class="combobox">
<div class="d-flex">
<input type="text" class="form-control border-bottom-0 rounded-bottom-0 rounded-end-0"
placeholder="Enter the description of a device type." id="input-devicetype"
oninput="enableDTAddButton()">
<button type="button"
class="btn btn-primary border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 disabled"
id="add-devicetype" onclick="addDTItem()" disabled>Add</button>
<button type="button"
class="btn btn-info border-bottom-0 rounded-bottom-0 rounded-start-0 rounded-end-0 d-none"
id="edit-devicetype" onclick="editDTItem()">Edit</button>
<button type="button" class="btn btn-danger border-bottom-0 rounded-bottom-0 rounded-start-0 disabled"
id="remove-devicetype" onclick="deleteDTItem()" disabled>Remove</button>
</div>
<div class="border h-100 ps-3 pe-0 overflow-auto" id="device-type-list">
{% for t in device_types %}
<div id="devicetype-option-{{ t['id'] }}" data-inv-id="{{ t['id'] }}"
class="d-flex justify-content-between align-items-center user-select-none dt-option">
<span class="align-middle dt-label">{{ t['description'] }}</span>
<input type="number"
class="form-control form-control-sm dt-target"
id="devicetype-target-{{ t['id'] }}" name="devicetype-target-{{ t['id'] }}"
value="{{ t['target'] if t['target'] else 0 }}" min="0" max="999">
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<label for="status" class="form-label">
Conditions
<a href="{{ url_for('entry.entry_new', model='status') }}"
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label>
{{ statuses | safe }}
</div> </div>
</div> </div>
</div> </div>
@ -37,11 +135,11 @@
<div class="tab-pane fade" id="location-tab-pane" tabindex="0"> <div class="tab-pane fade" id="location-tab-pane" tabindex="0">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<label for="area" class="form-label">Area</label> <label for="area" class="form-label">Areas</label>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }} {{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
</div> </div>
<div class="col"> <div class="col">
<label for="roomfunction" class="form-label">Description</label> <label for="roomfunction" class="form-label">Descriptions</label>
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }} {{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div> </div>
</div> </div>
@ -49,7 +147,8 @@
<div class="row mt-3"> <div class="row mt-3">
<label for="rooms" class="form-label"> <label for="rooms" class="form-label">
Rooms Rooms
<a href="{{ url_for('entry.entry_new', model='room') }}" class="link-success link-underline-opacity-0"><small>[+]</small></a> <a href="{{ url_for('entry.entry_new', model='room') }}"
class="link-success link-underline-opacity-0"><small>[+]</small></a>
</label> </label>
<div class="col"> <div class="col">
{{ rooms | safe }} {{ rooms | safe }}
@ -65,3 +164,264 @@
{% block scriptincludes %} {% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script> <script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
{% endblock %} {% endblock %}
{% block script %}
const brands = document.getElementById('brand');
const dtlist = document.getElementById('device-type-list');
const dtinput = document.getElementById('input-devicetype');
const height = getComputedStyle(brands).height;
dtlist.style.height = height;
dtlist.style.maxHeight = height;
dtlist.style.minHeight = height;
document.querySelectorAll('.dt-target').forEach((el) => {
el.addEventListener('change', async (ev) => {
const num = ev.target.value;
const id = ev.target.parentElement.dataset.invId;
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({target: num})
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 200) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error setting target number', 'danger');
return;
}
toastMessage('Updated target number.', 'success');
});
});
document.addEventListener('click', (ev) => {
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const deleteButton = document.getElementById('remove-devicetype');
if (!ev.target.closest('#device-type-list')) return;
// Do not toggle selection when interacting with the input itself
if (ev.target.closest('.dt-target')) return;
const node = ev.target.closest('.dt-option');
if (!node) return;
// clear others
document.querySelectorAll('.dt-option')
.forEach(n => { n.classList.remove('selected', 'active'); n.removeAttribute('aria-selected'); });
// select this one
node.classList.add('selected', 'active');
node.setAttribute('aria-selected', 'true');
// set the visible input to the label, not the whole row
const label = node.querySelector('.dt-label');
dtinput.value = (label ? label.textContent : node.textContent).replace(/\s+/g, ' ').trim();
addButton.classList.add('d-none');
editButton.classList.remove('d-none');
deleteButton.classList.remove('disabled');
deleteButton.disabled = false;
});
window.enableDTAddButton = function enableDTAddButton() {
const addButton = document.getElementById('add-devicetype');
if (addButton.classList.contains('d-none')) return;
addButton.disabled = dtinput.value === '';
if (addButton.disabled) {
addButton.classList.add('disabled');
} else {
addButton.classList.remove('disabled');
}
};
window.addDTItem = async function addDTItem() {
const input = document.getElementById('input-devicetype');
const list = document.getElementById('device-type-list');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const value = (input.value || '').trim();
if (!value) {
toastMessage('Type a device type first.', 'warning');
return;
}
addButton.disabled = true;
addButton.classList.add('disabled');
let res, data;
try {
const res = await fetch('{{ url_for("crudkit.devicetype.rest_create") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ description: value, target: 0 })
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 201) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error creating device type.', 'danger');
return;
} finally {
addButton.disabled = false;
addButton.classList.remove('disabled');
}
const id = data?.id ?? data?.obj?.id;
const description = String(data?.description ?? value);
const row = document.createElement('div');
row.id = `devicetype-option-${id}`;
row.dataset.invId = id;
row.className = 'd-flex justify-content-between align-items-center user-select-none dt-option';
const label = document.createElement('span');
label.className = 'align-middle dt-label';
label.textContent = description;
const qty = document.createElement('input');
qty.type = 'number';
qty.min = '0';
qty.max = '999';
qty.value = '0';
qty.id = `devicetype-target-${id}`;
qty.name = `devicetype-target-${id}`;
qty.className = 'form-control form-control-sm dt-target';
row.append(label, qty);
list.appendChild(row);
list.querySelectorAll('.dt-option').forEach(n => {
n.classList.remove('selected', 'active');
n.removeAttribute('aria-selected');
});
input.value = '';
row.scrollIntoView({ block: 'nearest' });
toastMessage(`Created new device type: ${description}`, 'success');
};
window.editDTItem = async function editDTItem() {
const input = document.getElementById('input-devicetype');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const option = document.querySelector('.dt-option.selected');
const value = (input.value || option.dataset.invId).trim();
if (!value) {
toastMessage('Type a device type first.', 'warning');
return;
}
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ description: value })
});
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
data = await res.json();
}
if (res.status !== 200) {
const msg = data?.error || `Create failed (${res.status})`;
toastMessage(msg, 'danger');
return;
}
} catch (err) {
toastMessage('Network error creating device type.', 'danger');
return;
} finally {
editButton.disabled = true;
editButton.classList.add('disabled', 'd-none');
addButton.classList.remove('d-none');
input.value = '';
}
option.querySelector('.dt-label').textContent = value;
toastMessage(`Updated device type: ${value}`, 'success');
}
window.deleteDTItem = async function deleteDTItem() {
const input = document.getElementById('input-devicetype');
const addButton = document.getElementById('add-devicetype');
const editButton = document.getElementById('edit-devicetype');
const option = document.querySelector('.dt-option.selected');
const deleteButton = document.getElementById('remove-devicetype');
const value = (input.value || '').trim();
let res, data;
try {
const res = await fetch(`{{ url_for('crudkit.devicetype.rest_list') }}${option.dataset.invId}`, {
method: 'DELETE',
headers: { 'Accept': 'application/json' }
});
if (res.ok) {
option.remove();
toastMessage(`Deleted ${value} successfully.`, 'success');
editButton.disabled = true;
editButton.classList.add('disabled', 'd-none');
deleteButton.disabled = true;
deleteButton.classList.add('disabled');
addButton.classList.remove('d-none');
input.value = '';
return;
}
let msg = 'Delete failed.';
try {
const err = await res.json();
msg = err?.error || msg;
} catch {
const txt = await res.text();
if (txt) msg = txt;
}
toastMessage(msg, 'danger');
} catch (e) {
toastMessage(`Delete failed: ${e?.message || e}`, 'danger');
}
}
{% endblock %}

View file

@ -1,24 +1,28 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block main %} {% block main %}
<h1 class="display-4 text-center mb-3">Inventory Summary</h1> <h1 class="display-4 text-center mb-3">Inventory Summary</h1>
<div class="table-responsive mx-5"> <div class="table-responsive mx-5 overflow-y-auto border" style="max-height: 70vh;">
<table class="table table-sm table-striped table-hover table-bordered align-middle"> <table class="table table-sm table-striped table-hover table-bordered align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th class="text-nowrap">Device Type</th> <th class="text-nowrap position-sticky top-0 bg-body border">Device Type</th>
{% for col in col_headers %} {% for col in col_headers %}
{% if col.href %} {% if col.href %}
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th> <th class="text-end position-sticky top-0 bg-body border"><a
class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
{% else %} {% else %}
<th class="text-end">{{ col.label }}</th> <th class="text-end position-sticky top-0 bg-body border">{{ col.label }}</th>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in table_rows %} {% for row in table_rows %}
<tr> {% set need_more = (row['cells'][-2]['value'] | int > 0) %}
<th class="text-nowrap"> <tr
class="{% if need_more %}table-warning{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
<th class="text-nowrap{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
{% if row.href %} {% if row.href %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a> <a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %} {% else %}
@ -27,9 +31,13 @@
</th> </th>
{% for cell in row.cells %} {% for cell in row.cells %}
{% if cell.href %} {% if cell.href %}
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td> <td
class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
<a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
{% else %} {% else %}
<td class="text-end">{{ cell.value }}</td> <td
class="text-end{% if need_more and loop.index == (row.cells|length - 1) %} fw-bold{% endif %}{% if loop.index == table_rows|length %} position-sticky bottom-0 border{% endif %}">
{{ cell.value }}</td>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tr> </tr>

View file

@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% import 'components/draw.html' as draw %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/draw.css') }}">
{% endblock %}
{% block main %}
{% set jsonImage %}
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffff00","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[500,600,600,600,300,-600,600,600]},{"t":"s","cl":"#000000"},{"t":"e","p":[700,800,200,200,700,-200,200,200,-800,300,500,1000]},{"t":"s","f":false},{"t":"e","p":[500,600,600,600,300,-600,600,600,-2000,-1200,2500,2500]},{"t":"l","p":[400,700,500,-300,1200,300,-500,-300]}]}
{% endset %}
{% set jsonImage2 %}
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffe59e","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#fdc8fe"},{"t":"e","p":[100,100,2300,2300]},{"t":"s","cl":"#fbe6a1"},{"t":"e","p":[600,600,1300,1300]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[700,700,1100,1100]},{"t":"s","cl":"#000000","f":false},{"t":"e","p":[0,0,2500,2500,-2400,-2400,2300,2300,-1800,-1800,1300,1300,-1200,-1200,1100,1100]},{"t":"s","sw":37,"cl":"#ff0000"},{"t":"l","p":[600,500,100,-100,500,0,100,0,500,200,100,100,200,200,100,-100,-600,-400,0,-100,-800,300,-100,100,-400,800,0,-100,100,-400,-100,-100,200,1000,100,0,-100,-600,-100,-100,500,1000,100,-100,-200,-200,0,100,500,200,100,-100,200,-200,100,100,-500,0,-100,100,800,-400,100,100,0,-400,100,100,0,-300,100,0,-200,-100,0,-100,0,-400,0,100,-400,-200,100,0,-600,-200,100,-100,-300,100,0,100,-300,500,-100,-100,0,900,100,-100,1300,400,100,-100,200,-200,0,-100,100,-200,0,-100]},{"t":"s","cl":"#00ff00"},{"t":"l","p":[400,700,100,0,500,-200,0,-100,400,-200,0,100,100,200,100,100,200,-200,100,100,300,600,100,0,-400,-300,100,100,100,400,100,0,100,200,-100,0,-200,100,100,100,0,200,100,-100,-400,100,0,-100,-100,300,100,0,-200,200,0,-100,-100,-200,0,100,-200,200,100,0,-300,0,0,-100,200,-100,100,-100,-400,0,100,0,-300,100,100,0,-200,-300,0,100,-200,-100,100,0,-200,-100,0,-100,100,-100,0,-100,-300,-100,100,0,0,-200,100,0,100,-200,0,100,100,-400,100,0]},{"t":"s","cl":"#0000ff"},{"t":"l","p":[800,400,0,100,300,0,100,0,200,-100,100,-100,200,0,0,100,300,100,100,100,0,200,0,-100,0,300,100,0,-200,300,0,-100,100,200,100,0,-300,100,0,100,0,200,0,100,-200,-100,0,100,200,200,-100,-100,0,200,-100,0,-100,-100,0,-100,-100,200,-100,0,-200,100,0,-100,-300,100,100,-100,-100,-300,0,100,-300,100,100,0,-200,-100,100,0,-200,-100,-100,-100,0,-200,100,-100,0,-200,-100,-100,100,-400,-100,0,200,300,100,-100,100,-200,-100,-100,300,-100,100,0,-500,-100,-100,100,1300,100,100,100,-1200,900,100,0]}]}
{% endset %}
<div class="row">
<div class="col" style="height: 80vh">
{{ draw.drawWidget('test1') }}
</div>
<!-- div class="col">
{{ draw.drawWidget('test4') }}
</div>
</div>
<div class="row">
<div class="col">
{{ draw.drawWidget('test5') }}
</div>
<div class="col">
{{ draw.drawWidget('test6') }}
</div -->
<div class="col" style="height: 80vh;">
I am testing a thing.
{{ draw.viewWidget('test2', jsonImage) }}
{{ draw.viewWidget('test3', jsonImage2) }}
The thing has been tested.
</div>
</div>
{% endblock %}
{% block scriptincludes %}
<script type="module" src="{{ url_for('static', filename='js/components/grid/index.js') }}"></script>
{% endblock %}

View file

@ -1,8 +1,8 @@
{% set items = (field.template_ctx.instance.updates or []) %} {% set items = (field.template_ctx.instance.updates or []) %}
<div class="mt-3"> <div class="mt-3 form-floating">
<label class="form-label">Add update</label>
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea> <textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
<label class="form-label">Add update</label>
<div class="mt-2 d-flex gap-2"> <div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button> <button type="button" class="btn btn-primary btn-sm" onclick="addNewDraft()">Add</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
@ -16,7 +16,7 @@
<ul class="list-group mt-3"> <ul class="list-group mt-3">
{% for n in items %} {% for n in items %}
<li class="list-group-item"> <li class="list-group-item" id="note-{{ n.id }}">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div> <div class="me-3 w-100 markdown-body" id="editContainer{{ n.id }}"></div>
<script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script> <script type="application/json" id="md-{{ n.id }}">{{ n.content | tojson }}</script>
@ -63,44 +63,15 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script> <script>
// State (kept global for compatibility with your form serialization) // State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || []; window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || []; window.deletedIds = window.deletedIds || [];
// ---------- DRY UTILITIES ----------
function renderMarkdown(md) {
// One place to parse + sanitize
const raw = marked.parse(md || "");
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
}
function enhanceLinks(root) {
if (!root) return;
for (const a of root.querySelectorAll('a[href]')) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.classList.add('link-success', 'link-underline', 'link-underline-opacity-0', 'fw-semibold');
}
}
function enhanceTables(root) {
if (!root) return;
for (const t of root.querySelectorAll('table')) {
t.classList.add('table', 'table-sm', 'table-striped', 'table-bordered');
}
}
function renderHTML(el, md) {
if (!el) return;
el.innerHTML = renderMarkdown(md);
enhanceLinks(el);
enhanceTables(el);
}
function getMarkdown(id) { function getMarkdown(id) {
const el = document.getElementById(`md-${id}`); return readJSONScript(`md-${id}`, "");
return el ? JSON.parse(el.textContent || '""') : "";
} }
function setMarkdown(id, md) { function setMarkdown(id, md) {
@ -114,14 +85,14 @@
function autoGrow(ta) { function autoGrow(ta) {
if (!ta) return; if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 5) + 'px'; ta.style.height = (ta.scrollHeight + 5) + 'px';
} }
// ---------- RENDERERS ---------- // ---------- RENDERERS ----------
function renderExistingView(id) { function renderExistingView(id) {
const container = document.getElementById(`editContainer${id}`); MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
renderHTML(container, getMarkdown(id));
} }
function renderEditor(id) { function renderEditor(id) {
@ -159,8 +130,7 @@
const left = document.createElement('div'); const left = document.createElement('div');
left.className = 'w-100 markdown-body'; left.className = 'w-100 markdown-body';
left.innerHTML = renderMarkdown(md || ''); MarkDown.renderInto(left, md || '');
enhanceLinks(left);
const right = document.createElement('div'); const right = document.createElement('div');
right.className = 'ms-3 d-flex flex-column align-items-end'; right.className = 'ms-3 d-flex flex-column align-items-end';
@ -263,7 +233,7 @@
if (!preview) return; if (!preview) return;
preview.classList.toggle('d-none'); preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) { if (!preview.classList.contains('d-none')) {
preview.innerHTML = renderMarkdown(ta ? ta.value : ""); MarkDown.renderInto(preview, ta ? ta.value : "");
} }
} }

View file

@ -0,0 +1,33 @@
<label class="form-label mt-2">Assigned Inventory</label>
{% set inv = field['template_ctx']['values']['inventory'] %}
<div class="table-responsive border overflow-y-auto" style="max-height: 45vh;">
<table class="table table-sm table-bordered table-striped table-hover mb-0">
{% if inv %}
<thead>
<tr>
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
<th class="position-sticky top-0 bg-body z-1 border">Brand</th>
<th class="position-sticky top-0 bg-body z-1 border">Model</th>
<th class="position-sticky top-0 bg-body z-1 border">Type</th>
</tr>
</thead>
<tbody>
{% for i in inv if i['condition.category'] not in ['Disposed', 'Administrative'] %}
<tr style="cursor: pointer;" onclick="location.href='{{ url_for('entry.entry', model='inventory', id=i.id) }}'">
<td>{{ i.label }}</td>
<td>{{ i['brand.name'] }}</td>
<td>{{ i.model }}</td>
<td>{{ i['device_type.description'] }}</td>
</tr>
{% endfor %}
</tbody>
{% else %}
<thead>
<tr>
<th colspan="4" class="text-center">No data.</th>
</tr>
</thead>
{% endif %}
</table>
</div>

View file

@ -0,0 +1,37 @@
<!-- WORK LOGS -->
<label class="form-label mt-2">Work Logs</label>
{% set wl = field['template_ctx']['values']['work_logs'] %}
{% set check %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg text-success" viewBox="0 0 16 16">
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/>
</svg>
{% endset %}
{% set x %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg text-danger" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
</svg>
{% endset %}
<div class="table-responsive border overflow-y-auto" style="max-height: 45vh;">
<table class="table table-sm table-striped table-bordered table-hover mb-0">
<thead>
<tr>
<th class="position-sticky top-0 bg-body z-1 border">Device</th>
<th class="position-sticky top-0 bg-body z-1 border">Start</th>
<th class="position-sticky top-0 bg-body z-1 border">End</th>
<th class="position-sticky top-0 bg-body z-1 border"></th>
</tr>
</thead>
<tbody>
{% for l in wl %}
<tr onclick="location.href='{{ url_for('entry.entry', model='worklog', id=l.id) }}'" style="cursor: pointer;">
<td>{{ l['work_item']['label'] if l['work_item'] else '-' }}</td>
<td>{{ l.start_time if l.start_time else '-' }}</td>
<td>{{ l.end_time if l.end_time else '-' }}</td>
<td class="text-center align-items-center {{ 'bg-success-subtle' if l.complete else 'bg-danger-subtle' }}">{{ check if l.complete else x }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>