Compare commits

..

2 commits

Author SHA1 Message Date
Yaro Kasear
dd863dba99 More attempts to fix a bug. 2025-10-21 08:54:15 -05:00
Yaro Kasear
bd2daf921a This introduces an uglt regression. 2025-10-20 15:20:52 -05:00
59 changed files with 668 additions and 4863 deletions

1
.gitignore vendored
View file

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

View file

@ -7,7 +7,7 @@ from hashlib import md5
from urllib.parse import urlencode
from werkzeug.exceptions import HTTPException
from crudkit.core.service import _is_truthy
from crudkit.core.params import is_truthy
MAX_JSON = 1_000_000
@ -39,7 +39,7 @@ def _json_error(e: Exception, status: int = 400):
return jsonify({"status": "error", "error": msg}), status
def _bool_param(d: dict[str, str], key: str, default: bool) -> bool:
return _is_truthy(d.get(key, "1" if default else "0"))
return is_truthy(d.get(key, "1" if default else "0"))
def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, rest: bool = True, rpc: bool = True):
"""
@ -60,8 +60,11 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
DELETE /api/<model>/delete?id=123[&hard=1]
"""
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(model_name, __name__, url_prefix=f"/api/{model_name}")
bp = Blueprint(plural, __name__, url_prefix=f"/api/{plural}")
@bp.errorhandler(Exception)
def _handle_any(e: Exception):
@ -102,7 +105,7 @@ def generate_crud_blueprint(model, service, *, base_prefix: str | None = None, r
obj = service.create(payload)
resp = jsonify(obj.as_dict())
resp.status_code = 201
resp.headers["Location"] = url_for(f"{bp.name}.rest_get", obj_id=obj.id, _external=False)
resp.headers["Location"] = url_for(f"{plural}.rest_get", obj_id=obj.id, _external=False)
return resp
except Exception as e:
return _json_error(e)

View file

@ -1,22 +1,11 @@
from functools import lru_cache
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper
from sqlalchemy import Column, Integer, DateTime, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, Mapper
from sqlalchemy.orm.state import InstanceState
Base = declarative_base()
from crudkit.core.meta import column_names_for_model
@lru_cache(maxsize=512)
def _column_names_for_model(cls: type) -> tuple[str, ...]:
try:
mapper = inspect(cls)
return tuple(prop.key for prop in mapper.column_attrs)
except Exception:
names: list[str] = []
for c in cls.__mro__:
if hasattr(c, "__table__"):
names.extend(col.name for col in c.__table__.columns)
return tuple(dict.fromkeys(names))
Base = declarative_base()
def _sa_state(obj: Any) -> Optional[InstanceState[Any]]:
"""Safely get SQLAlchemy InstanceState (or None)."""
@ -60,7 +49,16 @@ def _safe_get_loaded_attr(obj, name):
if attr is not None:
val = attr.loaded_value
return None if val is NO_VALUE else val
try:
# In rare cases, state.dict may be stale; reject descriptors
got = getattr(obj, name, None)
from sqlalchemy.orm.attributes import InstrumentedAttribute as _Instr
if got is not None and not isinstance(got, _Instr):
# Do not trigger load; only return if it was already present in __dict__
if hasattr(obj, "__dict__") and name in obj.__dict__:
return got
except Exception:
pass
return None
except Exception:
return None
@ -72,16 +70,10 @@ def _identity_key(obj) -> Tuple[type, Any]:
except Exception:
return (type(obj), id(obj))
def _is_collection_rel(prop: RelationshipProperty) -> bool:
try:
return prop.uselist is True
except Exception:
return False
def _serialize_simple_obj(obj) -> Dict[str, Any]:
"""Columns only (no relationships)."""
out: Dict[str, Any] = {}
for name in _column_names_for_model(type(obj)):
for name in column_names_for_model(type(obj)):
try:
out[name] = getattr(obj, name)
except Exception:
@ -153,9 +145,11 @@ def _split_field_tokens(fields: Iterable[str]) -> Tuple[List[str], Dict[str, Lis
def _deep_get_loaded(obj: Any, dotted: str) -> Any:
"""
Deep get with no lazy loads:
- For all but the final hop, use _safe_get_loaded_attr (mapped-only, no getattr).
- For the final hop, try _safe_get_loaded_attr first; if None, fall back to getattr()
to allow computed properties/hybrids that rely on already-loaded columns.
- Intermediate hops via _safe_get_loaded_attr only.
- Final hop:
* If relationship and not loaded: return None
* else allow getattr for non-relationship attrs (hybrids/properties) that compute from already-loaded data.
* Serialize ORM objects at the lead into dicts
"""
parts = dotted.split(".")
if not parts:
@ -171,30 +165,57 @@ def _deep_get_loaded(obj: Any, dotted: str) -> Any:
return None
last = parts[-1]
# Try safe fetch on the last hop first
val = _safe_get_loaded_attr(cur, last)
if val is not None:
# If last is an ORM relationship, only return it if already loaded.
m = _sa_mapper(cur)
if m is not None and last in m.relationships:
val = _safe_get_loaded_attr(cur, last)
if val is None:
return None
# serialize relationship value
if _sa_mapper(val) is not None:
return _serialize_simple_obj(val)
if isinstance(val, (list, tuple)):
out = []
for v in val:
out.append(_serialize_simple_obj(v) if _sa_mapper(v) is not None else v)
return out
return val
# Fall back to getattr for computed/hybrid attributes on an already-loaded object
try:
return getattr(cur, last, None)
except Exception:
return None
# Not a relationship: try loaded value, else safe getattr
val = _safe_get_loaded_attr(cur, last)
if val is None:
try:
# getattr here will not lazy-load relationships because we already gated those
val = getattr(cur, last, None)
except Exception:
val = None
if _sa_mapper(val) is not None:
return _serialize_simple_obj(val)
if isinstance(val, (list, tuple)):
return [_serialize_simple_obj(v) if _sa_mapper(v) is not None else v for v in val]
return val
def _serialize_leaf(obj: Any) -> Any:
"""
Lead serialization for values we put into as_dict():
- If object has as_dict(), call as_dict() with no args (caller controls field shapes).
- Else return value as-is (Flask/JSON encoder will handle datetimes, etc., via app config).
Leaf serialization for non-dotted scalar fields:
- If it's an ORM object with as_dict(), use it.
- Else if it's an ORM object, serialize columns only.
- Else return the value as-is.
"""
if obj is None:
return None
ad = getattr(obj, "as_dict", None)
if callable(ad):
try:
return ad(None)
except Exception:
return str(obj)
if _sa_mapper(obj) is not None:
ad = getattr(obj, "as_dict", None)
if callable(ad):
try:
return ad() # no args, your default handles fields selection
except Exception:
pass
return _serialize_simple_obj(obj)
return obj
def _serialize_collection(items: Iterable[Any], requested_tails: List[str]) -> List[Dict[str, Any]]:
@ -319,8 +340,6 @@ class CRUDMixin:
if mapper is not None:
out[name] = _serialize_simple_obj(val)
continue
# If it's a collection and no subfields were requested, emit a light list
if isinstance(val, (list, tuple)):
out[name] = [_serialize_leaf(v) for v in val]
else:

26
crudkit/core/meta.py Normal file
View file

@ -0,0 +1,26 @@
from __future__ import annotations
from functools import lru_cache
from typing import NamedTuple, Any, Dict, Tuple
from sqlalchemy import inspect
from sqlalchemy.orm import Mapper
class RelInfo(NamedTuple):
key: str
uselist: bool
target_cls: type | None
@lru_cache(maxsize=512)
def mapper_info(model: type) -> Dict[str, Any]:
m: Mapper = inspect(model)
cols = tuple(prop.key for prop in m.column_attrs)
rels = { r.key: RelInfo(r.key, bool(r.uselist), getattr(r.mapper, "class_", None))
for r in m.relationships }
pks = tuple(c.key for c in m.primary_key)
table = getattr(model, "__table__", None)
return {"cols": cols, "rels": rels, "pks": pks, "table": table}
def rel_map(model: type) -> Dict[str, RelInfo]:
return mapper_info(model)["rels"]
def column_names_for_model(model: type) -> Tuple[str, ...]:
return mapper_info(model)["cols"]

16
crudkit/core/params.py Normal file
View file

@ -0,0 +1,16 @@
def is_truthy(val) -> bool:
return str(val).lower() in ('1', 'true', 'yes', 'on')
def normalize_fields_param(params: dict | None) -> list[str]:
if not params:
return []
raw = params.get("fields")
if isinstance(raw, (list, tuple)):
out: list[str] = []
for item in raw:
if isinstance(item, str):
out.extend([p for p in (s.strip() for s in item.split(",")) if p])
return out
if isinstance(raw, str):
return [p for p in (s.strip() for s in raw.split(",")) if p]
return []

View file

@ -1,18 +1,19 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from flask import current_app
from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
from sqlalchemy import and_, func, inspect, or_, text, select, literal
from sqlalchemy import and_, func, inspect, or_, text
from sqlalchemy.engine import Engine, Connection
from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload, with_loader_criteria, aliased, with_parent
from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload, with_loader_criteria
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql import operators, visitors
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
from crudkit.core import to_jsonable, deep_diff, diff_to_patch, filter_to_columns, normalize_payload
from crudkit.core.base import Version
from crudkit.core.meta import rel_map, column_names_for_model
from crudkit.core.params import is_truthy, normalize_fields_param
from crudkit.core.spec import CRUDSpec, CollPred
from crudkit.core.types import OrderSpec, SeekWindow
from crudkit.backend import BackendInfo, make_backend_info
@ -139,23 +140,6 @@ def _dedupe_order_by(order_by):
out.append(ob)
return out
def _is_truthy(val):
return str(val).lower() in ('1', 'true', 'yes', 'on')
def _normalize_fields_param(params: dict | None) -> list[str]:
if not params:
return []
raw = params.get("fields")
if isinstance(raw, (list, tuple)):
out: list[str] = []
for item in raw:
if isinstance(item, str):
out.extend([p for p in (s.strip() for s in item.split(",")) if p])
return out
if isinstance(raw, str):
return [p for p in (s.strip() for s in raw.split(",")) if p]
return []
# ---------------------------- CRUD service ----------------------------
class CRUDService(Generic[T]):
@ -200,7 +184,7 @@ class CRUDService(Generic[T]):
def _apply_soft_delete_criteria_for_children(self, query, plan: "CRUDService._Plan", params):
# Skip if caller explicitly asked for deleted
if _is_truthy((params or {}).get("include_deleted")):
if is_truthy((params or {}).get("include_deleted")):
return query
seen = set()
@ -218,7 +202,8 @@ class CRUDService(Generic[T]):
with_loader_criteria(
target_cls,
lambda cls: cls.is_deleted == False,
include_aliases=True
include_aliases=True,
propagate_to_loaders=True,
)
)
return query
@ -248,21 +233,18 @@ class CRUDService(Generic[T]):
order_spec = self._extract_order_spec(root_alias, plan.order_by)
# Inner subquery must be ordered exactly like the real query
# Inner subquery ordered exactly like the real query
inner = query.order_by(*self._order_clauses(order_spec, invert=False))
# IMPORTANT: Build subquery that actually exposes the order-by columns
# under predictable names, then select FROM that and reference subq.c[...]
subq = inner.with_entities(*order_spec.cols).subquery()
# Label each order-by column with deterministic, unique names
labeled_cols = []
for idx, col in enumerate(order_spec.cols):
base = getattr(col, "key", None) or getattr(col, "name", None)
name = f"ord_{idx}_{base}" if base else f"ord_{idx}"
labeled_cols.append(col.label(name))
# Map the order columns to the subquery columns by key/name
cols_on_subq = []
for col in order_spec.cols:
key = getattr(col, "key", None) or getattr(col, "name", None)
if not key:
# Fallback, but frankly your order cols should have names
raise ValueError("Order-by column is missing a key/name")
cols_on_subq.append(getattr(subq.c, key))
subq = inner.with_entities(*labeled_cols).subquery()
cols_on_subq = [getattr(subq.c, c.key) for c in labeled_cols]
# Now the outer anchor query orders and offsets on the subquery columns
anchor_q = (
@ -282,7 +264,7 @@ class CRUDService(Generic[T]):
return list(row) # tuple-like -> list for _key_predicate
def _apply_not_deleted(self, query, root_alias, params):
if self.supports_soft_delete and not _is_truthy((params or {}).get("include_deleted")):
if self.supports_soft_delete and not is_truthy((params or {}).get("include_deleted")):
return query.filter(getattr(root_alias, "is_deleted") == False)
return query
@ -299,7 +281,10 @@ class CRUDService(Generic[T]):
def _stable_order_by(self, root_alias, given_order_by):
order_by = list(given_order_by or [])
if not order_by:
# Safe default: primary key(s) only. No unlabeled expressions.
return _dedupe_order_by(self._default_order_by(root_alias))
# Dedupe what the user gave us, then ensure PK tie-breakers exist
order_by = _dedupe_order_by(order_by)
mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
present = {_order_identity(_unwrap_ob(ob)[0]) for ob in order_by}
@ -362,7 +347,7 @@ class CRUDService(Generic[T]):
proj_opts: Any
def _plan(self, params, root_alias) -> _Plan:
req_fields = _normalize_fields_param(params)
req_fields = normalize_fields_param(params)
spec = CRUDSpec(self.model, params or {}, root_alias)
filters = spec.parse_filters()
@ -373,7 +358,16 @@ class CRUDService(Generic[T]):
join_paths = tuple(spec.get_join_paths())
filter_tables = _collect_tables_from_filters(filters)
fkeys = set()
_, proj_opts = compile_projection(self.model, req_fields) if req_fields else ([], [])
# Build projection opts only if there are true scalar columns requested.
# Bare relationship fields like "owner" should not force root column pruning.
column_names = set(column_names_for_model(self.model))
has_scalar_column_tokens = any(
(("." not in f) and (f in column_names))
for f in (req_fields or [])
)
_, proj_opts = (compile_projection(self.model, req_fields)
if (req_fields and has_scalar_column_tokens)
else ([], []))
# filter_tables = ()
# fkeys = set()
@ -391,73 +385,100 @@ class CRUDService(Generic[T]):
return query.options(Load(root_alias).load_only(*only_cols)) if only_cols else query
def _apply_firsthop_strategies(self, query, root_alias, plan: _Plan):
nested_first_hops = { p[0] for p in (plan.rel_field_names or {}).keys() if len(p) > 1 }
joined_rel_keys = set()
joined_rel_keys: set[str] = set()
rels = rel_map(self.model) # {name: RelInfo}
# Existing behavior: join everything in join_paths (to-one), selectinload collections
# Eager join to-one relationships requested as bare fields (e.g., fields=owner)
requested_scalars = set(plan.root_field_names or [])
for key in requested_scalars:
info = rels.get(key)
if info and not info.uselist and key not in joined_rel_keys:
query = query.join(getattr(root_alias, key), isouter=True)
joined_rel_keys.add(key)
# 1) Join to-one relationships explicitly requested as bare fields
requested_scalars = set(plan.root_field_names or []) # names like "owner", "supervisor"
for key in requested_scalars:
info = rels.get(key)
if info and not info.uselist and key not in joined_rel_keys:
query = query.join(getattr(root_alias, key), isouter=True)
joined_rel_keys.add(key)
# 2) Join to-one relationships from parsed join_paths
for base_alias, rel_attr, target_alias in plan.join_paths:
if base_alias is not root_alias:
continue
prop = getattr(rel_attr, "property", None)
is_collection = bool(getattr(prop, "uselist", False))
if not is_collection:
if prop and not prop.uselist:
query = query.join(target_alias, rel_attr.of_type(target_alias), isouter=True)
joined_rel_keys.add(prop.key if prop is not None else rel_attr.key)
else:
opt = selectinload(rel_attr)
child_names = (plan.collection_field_names or {}).get(rel_attr.key, [])
if child_names:
target_cls = prop.mapper.class_
cols = [getattr(target_cls, n, None) for n in child_names]
cols = [c for c in cols if isinstance(c, InstrumentedAttribute)]
if cols:
opt = opt.load_only(*cols)
query = query.options(opt)
# inside CRUDService._apply_firsthop_strategies
# ...
# 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.
# 3) Ensure to-one touched by filters is joined
if plan.filter_tables:
mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
for rel in mapper.relationships:
if rel.uselist:
continue # only first-hop to-one here
target_cls = rel.mapper.class_
target_tbl = getattr(target_cls, "__table__", None)
if target_tbl is None:
for key, info in rels.items():
if info.uselist or not info.target_cls:
continue
if target_tbl in plan.filter_tables:
if rel.key in joined_rel_keys:
continue # already joined via join_paths
target_tbl = getattr(info.target_cls, "__table__", None)
if target_tbl is not None and target_tbl in plan.filter_tables and key not in joined_rel_keys:
query = query.join(getattr(root_alias, key), isouter=True)
joined_rel_keys.add(key)
# 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)
# 4) Collections via selectinload, optionally load_only for requested child columns
for base_alias, rel_attr, _target_alias in plan.join_paths:
if base_alias is not root_alias:
continue
prop = getattr(rel_attr, "property", None)
if not prop or not prop.uselist:
continue
opt = selectinload(rel_attr)
child_names = (plan.collection_field_names or {}).get(prop.key, [])
if child_names:
target_cls = prop.mapper.class_
cols = [getattr(target_cls, n, None) for n in child_names]
cols = [c for c in cols if isinstance(c, InstrumentedAttribute)]
if cols:
opt = opt.load_only(*cols)
query = query.options(opt)
if log.isEnabledFor(logging.DEBUG):
info = []
for base_alias, rel_attr, target_alias in plan.join_paths:
if base_alias is not root_alias:
continue
prop = getattr(rel_attr, "property", None)
sel = getattr(target_alias, "selectable", None)
info.append({
"rel": (getattr(prop, "key", getattr(rel_attr, "key", "?"))),
"collection": bool(getattr(prop, "uselist", False)),
"target_keys": list(_selectable_keys(sel)) if sel is not None else [],
"joined": (getattr(prop, "key", None) in joined_rel_keys),
})
log.debug("FIRSTHOP: %s.%s first-hop paths: %s",
self.model.__name__, getattr(root_alias, "__table__", type(root_alias)).key,
info)
for path, _names in (plan.rel_field_names or {}).items():
if not path:
continue
# Build a chained selectinload for each relationship segment in the path
first = path[0]
info = rels.get(first)
if not info or info.target_cls is None:
continue
# Start with selectinload on the first hop
opt = selectinload(getattr(root_alias, first))
# Walk deeper segments
cur_cls = info.target_cls
for seg in path[1:]:
sub = rel_map(cur_cls).get(seg)
if not sub or sub.target_cls is None:
# if segment isn't a relationship, we stop the chain
break
opt = opt.selectinload(getattr(cur_cls, seg))
cur_cls = sub.target_cls
query = query.options(opt)
return query
def _apply_proj_opts(self, query, plan: _Plan):
return query.options(*plan.proj_opts) if plan.proj_opts else query
if not plan.proj_opts:
return query
try:
return query.options(*plan.proj_opts)
except KeyError as e:
# Seen "KeyError: 'col'" when alias-column remapping meets unlabeled exprs.
log.debug("Projection options disabled due to %r; proceeding without them.", e)
return query
except Exception as e:
log.debug("Projection options failed (%r); proceeding without them.", e)
return query
def _projection_meta(self, plan: _Plan):
if plan.req_fields:

View file

@ -11,8 +11,6 @@ from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
from typing import Any, Dict, List, Optional, Tuple
import crudkit
_ALLOWED_ATTRS = {
"class", "placeholder", "autocomplete", "inputmode", "pattern",
"min", "max", "step", "maxlength", "minlength",
@ -109,53 +107,6 @@ def register_template_globals(app=None):
app.add_template_global(fn, 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):
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
out = set(fields)
@ -696,12 +647,10 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
if "label_deps" in spec:
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 field["type"] is None:
field["type"] = "select"
if field["type"] == "select" and field.get("options") is None:
if field["type"] == "select" and field.get("options") is None and session is not None:
related_model = rel_prop.mapper.class_
label_spec = (
spec.get("label_spec")
@ -709,11 +658,7 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
or getattr(related_model, "__crud_label__", None)
or "id"
)
field["options"] = _fk_options_via_service(
related_model,
label_spec,
options_params=opts_params
)
field["options"] = _fk_options(session, related_model, label_spec)
return field
col = mapper.columns.get(name)

View file

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

View file

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

View file

@ -2,13 +2,12 @@ from __future__ import annotations
import os, logging, sys
from flask import Flask, render_template, request, current_app
from flask import Flask
from jinja_markdown import MarkdownExtension
from pathlib import Path
from sqlalchemy.engine import Engine
from sqlalchemy import event
from sqlalchemy.pool import Pool
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.profiler import ProfilerMiddleware
import crudkit
@ -18,13 +17,11 @@ from crudkit.integrations.flask import init_app
from .debug_pretty import init_pretty
from .routes.entry import init_entry_routes
from .routes.image import init_image_routes
from .routes.index import init_index_routes
from .routes.listing import init_listing_routes
from .routes.search import init_search_routes
from .routes.settings import init_settings_routes
from .routes.reports import init_reports_routes
from .routes.testing import init_testing_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__)
@ -45,27 +42,6 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
except Exception as e:
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)
print(f"Effective DB URL: {str(runtime.engine.url)}")
@ -93,20 +69,17 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
_models.Inventory,
_models.RoomFunction,
_models.Room,
_models.Status,
_models.User,
_models.WorkLog,
_models.WorkNote,
])
init_entry_routes(app)
init_image_routes(app)
init_index_routes(app)
init_listing_routes(app)
init_search_routes(app)
init_settings_routes(app)
init_reports_routes(app)
init_testing_routes(app)
@app.teardown_appcontext
def _remove_session(_exc):

View file

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

View file

@ -1,6 +1,6 @@
from typing import List, Optional
from sqlalchemy import Boolean, Integer, Unicode
from sqlalchemy import Boolean, Unicode
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql
@ -10,7 +10,6 @@ class DeviceType(Base, CRUDMixin):
__tablename__ = 'item'
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')

View file

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

View file

@ -1,33 +0,0 @@
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,8 +17,7 @@ class WorkLog(Base, CRUDMixin):
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)
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan',
order_by="desc(WorkNote.timestamp), desc(WorkNote.id)", lazy="selectin")
updates: Mapped[List['WorkNote']] = relationship('WorkNote', back_populates='work_log', cascade='all, delete-orphan', order_by='WorkNote.timestamp.desc()')
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)

View file

@ -9,7 +9,7 @@ from crudkit.core import normalize_payload
bp_entry = Blueprint("entry", __name__)
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room", "status"]
ENTRY_WHITELIST = ["inventory", "user", "worklog", "room"]
def _fields_for_model(model: str):
fields: list[str] = []
@ -27,37 +27,44 @@ def _fields_for_model(model: str):
"notes",
"owner.id",
"image.filename",
"image.caption",
]
fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control", "placeholder": "Device Name"}, "label_attrs": {"class": "ms-2"}},
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control", "placeholder": "Serial Number"}, "label_attrs": {"class": "ms-2"}},
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control", "placeholder": "Bar Code"}, "label_attrs": {"class": "ms-2"}},
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "ms-2"}},
{"name": "model", "row": "device", "wrap": {"class": "col form-floating"},
"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 form-floating"},
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "ms-2"}},
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}},
"options_params": {"active__eq": True}},
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"},
{"name": "name", "row": "names", "label": "Name", "wrap": {"class": "col-3"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "serial", "row": "names", "label": "Serial #", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "barcode", "row": "names", "label": "Barcode #", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}},
{"name": "brand", "label_spec": "{name}", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label": "Brand", "label_attrs": {"class": "form-label"}},
{"name": "model", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label": "Model #", "label_attrs": {"class": "form-label"}},
{"name": "device_type", "label_spec": "{description}", "row": "device", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label": "Device Type", "label_attrs": {"class": "form-label"}},
{"name": "owner", "row": "status", "label": "Contact", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"label_spec": "{label}", "link": {"endpoint": "entry.entry", "params": {"model": "user", "id": "{owner.id}"}}},
{"name": "location", "row": "status", "label": "Location", "wrap": {"class": "col"},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"label_spec": "{name} - {room_function.description}"},
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"},
"label_attrs": {"class": "ms-2"}, "label_spec": "{description}"},
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col"},
"type": "select", "options": [
{"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}",
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto", "data-model": "inventory"},
"wrap": {"class": "d-inline-block position-relative image-wrapper", "style": "min-width: 200px; min-height: 200px;"}},
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
"wrap": {"class": "h-100 w-100"}},
{"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
"template": "inventory_note.html"},
{"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"},
@ -82,44 +89,30 @@ def _fields_for_model(model: str):
"title",
"active",
"staff",
"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",
"supervisor.id"
]
fields_spec = [
{"name": "label", "row": "label", "label": "", "type": "display",
"attrs": {"class": "display-6 mb-3"}, "wrap": {"class": "col"}, "label_spec": "{label} ({title})"},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "ms-2"},
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "ms-2"},
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col form-floating"}},
{"name": "title", "label": "Title", "label_attrs": {"class": "ms-2"},
{"name": "last_name", "label": "Last Name", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "Doe", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
{"name": "first_name", "label": "First Name", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "John", "class": "form-control"}, "row": "name", "wrap": {"class": "col-3"}},
{"name": "title", "label": "Title", "label_attrs": {"class": "form-label"},
"attrs": {"placeholder": "President of the Universe", "class": "form-control"},
"row": "name", "wrap": {"class": "col form-floating"}},
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "ms-2 link-label"},
"label_spec": "{label}", "row": "details", "wrap": {"class": "col form-floating"},
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}},
"options_params": {"active__eq": True, "staff__eq": True}},
{"name": "location", "label": "Room", "label_attrs": {"class": "ms-2"},
"row": "name", "wrap": {"class": "col-3"}},
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
"label_spec": "{name} - {room_function.description}",
"row": "details", "wrap": {"class": "col form-floating"}, "attrs": {"class": "form-control"}},
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
{"name": "active", "label": "Active", "label_attrs": {"class": "form-check-label"},
"row": "checkboxes", "attrs": {"class": "form-check-input"}, "wrap": {"class": "form-check"}},
{"name": "staff", "label": "Staff Member", "label_attrs": {"class": "form-check-label"},
"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 = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
@ -127,7 +120,6 @@ def _fields_for_model(model: str):
{"name": "details", "order": 20, "attrs": {"class": "row mt-2"}},
{"name": "checkboxes", "order": 30, "parent": "details",
"attrs": {"class": "col d-flex flex-column justify-content-end"}},
{"name": "inventory", "order": 40, "attrs": {"class": "row"}},
]
elif model == "worklog":
@ -142,28 +134,26 @@ def _fields_for_model(model: str):
"updates.id",
"updates.content",
"updates.timestamp",
"updates.is_deleted"
"updates.is_deleted",
]
fields_spec = [
{"name": "id", "label": "", "type": "display", "label_spec": "Work Item #{id}",
"attrs": {"class": "display-6 mb-3"}, "row": "label", "wrap": {"class": "col"}},
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "contact", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Contact",
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2 link-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}},
"options_params": {"active__eq": True}},
{"name": "work_item", "row": "ownership", "wrap": {"class": "col form-floating"}, "label": "Work Item",
"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": "contact", "row": "ownership", "wrap": {"class": "col"}, "label": "Contact",
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{contact.id}", "model": "user"}}},
{"name": "work_item", "row": "ownership", "wrap": {"class": "col"}, "label": "Work Item",
"label_spec": "{label}", "attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"},
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}},
{"name": "start_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "Start"},
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "Start"},
{"name": "end_time", "type": "datetime", "attrs": {"class": "form-control"}, "row": "timestamps",
"wrap": {"class": "col form-floating"}, "label_attrs": {"class": "ms-2"}, "label": "End"},
"wrap": {"class": "col"}, "label_attrs": {"class": "form-label"}, "label": "End"},
{"name": "complete", "label": "Complete", "label_attrs": {"class": "form-check-label"},
"attrs": {"class": "form-check-input"}, "row": "timestamps", "wrap": {"class": "col form-check"}},
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "ms-2"},
{"name": "updates", "label": "Updates", "row": "updates", "label_attrs": {"class": "form-label"},
"type": "template", "template": "update_list.html"},
]
layout = [
@ -183,43 +173,18 @@ def _fields_for_model(model: str):
{"name": "buttons", "label": "", "row": "label", "type": "template", "template": "entry_buttons.html",
"wrap": {"class": "col-auto text-end me-2"}, "attrs": {"data-model": model}},
{"name": "name", "label": "Name", "row": "name", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col mb-3 form-floating"}},
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col mb-3"}},
{"name": "area", "label": "Area", "row": "details", "attrs": {"class": "form-control"},
"label_attrs": {"class": "ms-2"}, "wrap": {"class": "col form-floating"}, "label_spec": "{name}"},
"label_attrs": {"class": "form-label"}, "wrap": {"class": "col"}, "label_spec": "{name}"},
{"name": "room_function", "label": "Description", "label_spec": "{description}",
"attrs": {"class": "form-control"}, "label_attrs": {"class": "ms-2"}, "row": "details",
"wrap": {"class": "col form-floating"}},
"attrs": {"class": "form-control"}, "label_attrs": {"class": "form-label"}, "row": "details",
"wrap": {"class": "col"}},
]
layout = [
{"name": "label", "order": 0, "attrs": {"class": "row align-items-center"}},
{"name": "name", "order": 10, "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)
@ -350,11 +315,6 @@ def init_entry_routes(app):
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
updates = payload.pop("updates", []) or []
payload.pop("delete_update_ids", None) # irrelevant on create
@ -408,8 +368,6 @@ def init_entry_routes(app):
cls = crudkit.crud.get_model(model)
payload = normalize_payload(request.get_json(), cls)
image_caption = payload.pop("caption", None)
updates = payload.pop("updates", None) or []
delete_ids = payload.pop("delete_update_ids", None) or []
@ -468,13 +426,6 @@ def init_entry_routes(app):
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):
_apply_worklog_updates(obj, updates, delete_ids)

View file

@ -1,89 +0,0 @@
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,7 +7,6 @@ import crudkit
from crudkit.ui.fragments import render_table
from ..models.device_type import DeviceType
from ..models.inventory import Inventory
from ..models.work_log import WorkLog
@ -16,7 +15,6 @@ bp_index = Blueprint("index", __name__)
def init_index_routes(app):
@bp_index.get("/")
def index():
# 1. work log stuff (leave it)
work_log_service = crudkit.crud.get_service(WorkLog)
work_logs = work_log_service.list({
"complete__ne": 1,
@ -34,69 +32,10 @@ def init_index_routes(app):
{"field": "work_item.label", "label": "Work Item",
"link": {"endpoint": "entry.entry", "params": {"id": "{work_item.id}", "model": "inventory"}}}
]
logs = render_table(work_logs, columns=columns, opts={"object_class": "worklog"})
# 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)
return render_template("index.html", logs=logs)
@bp_index.get("/LICENSE")
def license():

View file

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

View file

@ -1,206 +1,89 @@
from datetime import datetime, timedelta
from email.utils import format_datetime
from flask import Blueprint, render_template, url_for, make_response, request
from flask import Blueprint, render_template, url_for
from urllib.parse import urlencode
import pandas as pd
from crudkit.ui.fragments import render_table
import crudkit
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):
@bp_reports.get('/summary')
def summary():
inventory_model = crudkit.crud.get_model('inventory')
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({
"limit": 0,
"sort": "device_type.description",
"fields": [
"id",
"device_type.description",
"condition.category", # enum
"condition.description", # not used for pivot, but handy to have
],
"fields": ["id", "condition", "device_type.description"],
})
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:
df = df.drop_duplicates(subset="id")
# Normalize text columns
df["device_type.description"] = (
df.get("device_type.description")
.fillna("(Unspecified)")
.astype(str)
pt = df.pivot_table(
index="device_type.description",
columns="condition",
values="id",
aggfunc="count",
fill_value=0,
)
# 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)
# Reorder/exclude like before
order = ["Deployed", "Working", "Partially Inoperable", "Inoperable", "Unverified"]
exclude = ["Removed", "Disposed"]
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[cols]
# 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(
index="device_type.description",
columns=cat_col,
values="id",
aggfunc="count",
fill_value=0,
)
if "target" in needs.columns:
needs["target"] = pd.to_numeric(needs["target"], errors="coerce").astype("Int64")
needs = needs.fillna({"target": pd.NA})
pt = pt.merge(needs, left_index=True, right_on="description")
# 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")
# Keep a handle on the category columns produced by the pivot BEFORE merge
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]
# Drop zero-only rows
pt = pt.loc[(pt != 0).any(axis=1)]
# Totals
if not pt.empty and ordered:
# Per-row totals (counts only)
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["Total"] = pt.sum(axis=1)
total_row = pt.sum(axis=0).to_frame().T
total_row.index = ["Total"]
pt = pd.concat([pt, total_row], axis=0)
# Strip pandas names
# Names off
pt.index.name = None
pt.columns.name = None
# Construct headers from the exact columns in the pivot (including Total if present)
# Build link helpers. url_for can't take dotted kwarg keys, so build query strings.
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
columns_for_render = list(pt.columns) if not pt.empty else []
# 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)
def q(h):
return f"{base_list_url}?{urlencode(h)}" if h else None
# Column headers with links (except Total)
col_headers = []
for col in columns_for_render:
# Only make category columns clickable; planning/Total are informational
if col == "Total" or col in planning_cols:
col_headers.append({"label": label_for(col), "href": None})
for col in pt.columns.tolist():
if col == "Total":
col_headers.append({"label": col, "href": None})
else:
col_headers.append({"label": label_for(col), "href": q({cat_col: col})})
col_headers.append({"label": col, "href": q({"condition": col})})
# Build rows. Cells iterate over the SAME list used for headers. No surprises.
# Rows with header links and cell links
table_rows = []
index_for_render = list(pt.index) if not pt.empty else sorted(df["device_type.description"].unique())
for idx in index_for_render:
is_total_row = (idx == "Total")
row_href = None if is_total_row else q({"device_type.description": idx})
for idx in pt.index.tolist():
# Row header link: only if not Total
if idx == "Total":
row_href = None
else:
row_href = q({"device_type.description": idx})
# Cells: combine filters, respecting Total row/col rules
cells = []
for col in columns_for_render:
# 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):,}"
for col in pt.columns.tolist():
val = int(pt.at[idx, col])
params = {}
if not is_total_row:
if idx != "Total":
params["device_type.description"] = idx
if col not in ("Total", *planning_cols):
params[cat_col] = col
href = q(params) if params else None
cells.append({"value": s, "href": href})
if col != "Total":
params["condition"] = col
href = q(params) if params else None # None for Total×Total
cells.append({"value": f"{val:,}", "href": href})
table_rows.append({"label": idx, "href": row_href, "cells": cells})
return render_template(
@ -209,83 +92,4 @@ def init_reports_routes(app):
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)

View file

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

View file

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

View file

@ -1,12 +0,0 @@
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

@ -1,222 +0,0 @@
: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

@ -1,12 +0,0 @@
.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

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

View file

@ -1,132 +0,0 @@
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

@ -1,438 +0,0 @@
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

@ -1,117 +0,0 @@
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

@ -1,23 +0,0 @@
(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

@ -1,46 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,90 +0,0 @@
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

@ -1,472 +0,0 @@
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

@ -1,831 +0,0 @@
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

@ -1,136 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,105 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,7 +0,0 @@
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,7 +40,6 @@
<a class="nav-link dropdown-toggle link-success fw-semibold" data-bs-toggle="dropdown">Reports</a>
<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.problems') }}">Problems</a></li>
</ul>
</li>
</ul>

View file

@ -1,266 +0,0 @@
{% 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,103 +1,83 @@
<!-- FIELD: {{ field_name }} ({{ field_type }}) -->
{# show label unless hidden/custom #}
{% 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' %}
{#
<select name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %} {%- if not options %} disabled{% endif %}>
{% if options %}
<option value="">-- Select --</option>
{% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string==value|string %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
{% else %}
<option value="">-- No selection available --</option>
{% endif %}
</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 '' }}">
<select name="{{ field_name }}" id="{{ field_name }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}
{%- if not options %} disabled{% endif %}>
{% if options %}
<option value="">-- Select --</option>
{% for opt in options %}
<option value="{{ opt.value }}" {% if opt.value|string == value|string %}selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
{% else %}
<option value="">-- No selection available --</option>
{% endif %}
</select>
{% elif field_type == 'textarea' %}
<textarea name="{{ field_name }}" id="{{ field_name }}" {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not
sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
<textarea name="{{ field_name }}" id="{{ field_name }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value if value else "" }}</textarea>
{% elif field_type == 'checkbox' %}
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1" {% if value %}checked{% endif %} {% if
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
%}>
<input type="checkbox" name="{{ field_name }}" id="{{ field_name }}" value="1"
{% if value %}checked{% endif %}
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% 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' %}
<div {% if attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor
%}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
<div {% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
{% elif field_type == "date" %}
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "time" %}
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% elif field_type == "datetime" %}
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if
attrs %}{% for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif
%}>
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% else %}
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}" {% if attrs %}{%
for k,v in attrs.items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
{% if attrs %}{% for k,v in attrs.items() %}
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
{% endfor %}{% endif %}>
{% endif %}
{% if help %}
<div class="form-text">{{ help }}</div>
<div class="form-text">{{ help }}</div>
{% 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,6 +1,5 @@
<!-- TABLE {{ kwargs['object_class'] if kwargs else '(NO MODEL ASSOCIATED)' }} -->
<div class="table-responsive" style="max-height: 80vh;">
<table class="table table-sm table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<table class="table table-info table-striped table-hover table-bordered border-tertiary text-nowrap overflow-x-auto mx-auto">
<thead>
<tr>
{% for col in columns %}

View file

@ -1,18 +1,8 @@
{% 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 %}
<div class="container mt-5">
{{ form | safe }}
</div>
{% 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 %}
{% endblock %}

View file

@ -1,163 +1,80 @@
<div class="btn-group">
<button type="submit" class="btn btn-outline-primary" id="submit">Save</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>
<button type="submit" class="btn btn-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>
</div>
<script>
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
// 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' },
function formToJson(form) {
const fd = new FormData(form);
const out = {};
fd.forEach((value, key) => {
if (key in out) {
if (!Array.isArray(out[key])) out[key] = [out[key]];
out[key].push(value);
} else {
out[key] = value;
}
});
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 };
form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => {
if (!el.name) return;
if (el.type === 'radio') {
if (out[el.name] !== undefined) return;
const checked = form.querySelector(`input[type="radio"][name="${CSS.escape(el.name)}"]:checked`);
if (checked) out[el.name] = checked.value ?? true;
return;
}
}
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 (el.type === 'checkbox') {
const group = form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(el.name)}"]`);
out[el.name] = group.length > 1
? Array.from(group).filter(i => i.checked).map(i => i.value ?? true)
: el.checked;
}
});
return out;
}
}
{% 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) });
function collectExistingUpdateIds() {
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
{% endif %}
function formToJson(form) {
const fd = new FormData(form);
const out = {};
fd.forEach((value, key) => {
if (key in out) {
if (!Array.isArray(out[key])) out[key] = [out[key]];
out[key].push(value);
} else {
out[key] = value;
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) });
}
});
form.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(el => {
if (!el.name) return;
if (el.type === 'radio') {
if (out[el.name] !== undefined) return;
const checked = form.querySelector(`input[type="radio"][name="${CSS.escape(el.name)}"]:checked`);
if (checked) out[el.name] = checked.value ?? true;
return;
}
if (el.type === 'checkbox') {
const group = form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(el.name)}"]`);
out[el.name] = group.length > 1
? Array.from(group).filter(i => i.checked).map(i => i.value ?? true)
: el.checked;
}
});
return out;
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
// URLs for create/update
const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }};
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
formEl.addEventListener('submit', async e => {
e.preventDefault();
// 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;
const submitBtn = document.getElementById('submit');
submitBtn.disabled = true;
// 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 }};
// Update URL is assembled on the client to avoid BuildError on "new":
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
formEl.addEventListener("submit", async e => {
e.preventDefault();
try {
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') {
const md = getMarkdown();
json.notes = (typeof md === 'string') ? md.trim() : '';
json.notes = (typeof md === 'string') ? getMarkdown().trim() : '';
} else if (model === 'worklog') {
json.updates = collectEditedUpdates();
json.delete_update_ids = collectDeletedIds();
@ -167,43 +84,38 @@ formEl.addEventListener('submit', async e => {
const url = hasId ? updateUrl : createUrl;
console.log('Submitting JSON:', json);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
});
const reply = await res.json();
if (reply.status === 'success') {
if (!hasId && reply.id) {
window.queueToast('Created successfully.', 'success');
window.newDrafts = [];
window.deletedIds = [];
window.location.assign(`/entry/${model}/${reply.id}`);
return;
} else {
window.queueToast('Updated successfully.', 'success');
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
credentials: 'same-origin'
});
if (model === 'worklog') {
for (const id of collectDeletedIds()) {
const li = document.getElementById(`note-${id}`);
if (li) li.remove();
}
}
const reply = await res.json();
if (reply.status === 'success') {
window.newDrafts = [];
window.deletedIds = [];
if (!hasId && reply.id) {
queueToast('Created successfully.', 'success');
location.assign(`/entry/${model}/${reply.id}`);
return;
}
queueToast('Updated successfully.', 'success');
if (model === 'worklog') {
for (const id of collectDeletedIds()) {
document.getElementById(`note-${id}`)?.remove();
window.newDrafts = [];
window.deletedIds = [];
window.location.replace(window.location.href);
return;
}
}
location.replace(location.href);
} else {
toastMessage(reply.message || 'Server reported failure.', 'danger');
} catch (err) {
toastMessage(`Network error: ${String(err)}`, 'danger');
}
} catch (err) {
toastMessage(`Network error: ${String(err)}`, 'danger');
} finally {
submitBtn.disabled = false;
}
});
</script>
});
</script>

View file

@ -1,8 +0,0 @@
{% 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

@ -1,8 +0,0 @@
{% 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,43 +1,6 @@
{% 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 %}
<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 %}
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% else %}
<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 %}
<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>
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100">
{% endif %}

View file

@ -5,44 +5,9 @@
<p class="lead text-center">Find out about all of your assets.</p>
<div class="row mx-5">
<div class="col">
<div class="col pivot-cell ms-5">
<p class="display-6 text-center">Active Worklogs</p>
{{ logs | safe }}
</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>
{% endblock %}
{% endblock %}

View file

@ -28,76 +28,86 @@
<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="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
});
document.addEventListener('DOMContentLoaded', () => {
renderView(getMarkdown());
});
// used by entry_buttons submit
window.getMarkdown = function () {
return readJSONScript('noteContent', "");
};
function setMarkdown(md) {
const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? "");
}
function changeMode() {
const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch');
if (!toggle.checked) {
MarkDown.renderInto(container, getMarkdown());
return;
function getMarkdown() {
const el = document.getElementById('noteContent');
return el ? (JSON.parse(el.textContent) || "") : "";
}
const current = getMarkdown();
container.innerHTML = `
<textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
}
function saveEdit() {
const ta = document.getElementById('editor');
const value = ta ? ta.value : "";
setMarkdown(value);
MarkDown.renderInto(document.getElementById('editContainer'), value);
document.getElementById('editSwitch').checked = false;
}
function cancelEdit() {
document.getElementById('editSwitch').checked = false;
MarkDown.renderInto(document.getElementById('editContainer'), getMarkdown());
}
function togglePreview() {
const ta = document.getElementById('editor');
const preview = document.getElementById('preview');
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
MarkDown.renderInto(preview, ta ? ta.value : "");
function setMarkdown(md) {
const el = document.getElementById('noteContent');
if (el) el.textContent = JSON.stringify(md ?? "");
}
}
function autoGrow(ta) {
if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
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 escapeForTextarea(s) {
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
function changeMode() {
const container = document.getElementById('editContainer');
const toggle = document.getElementById('editSwitch');
if (!toggle.checked) return renderView(getMarkdown());
const current = getMarkdown();
container.innerHTML = `
<textarea class="form-control w-100 auto-md" id="editor" name="notes">${escapeForTextarea(current)}</textarea>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="saveEdit()">Save</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="togglePreview()">Preview</button>
</div>
<div class="mt-2 border rounded p-2 d-none" id="preview" aria-live="polite"></div>
`;
const ta = document.getElementById('editor');
autoGrow(ta);
ta.addEventListener('input', () => autoGrow(ta));
}
function saveEdit() {
const ta = document.getElementById('editor');
const value = ta ? ta.value : "";
setMarkdown(value);
renderView(value);
document.getElementById('editSwitch').checked = false;
}
function cancelEdit() {
document.getElementById('editSwitch').checked = false;
renderView(getMarkdown());
}
function togglePreview() {
const ta = document.getElementById('editor');
const preview = document.getElementById('preview');
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
const html = marked.parse(ta ? ta.value : "");
preview.innerHTML = DOMPurify.sanitize(html);
}
}
function autoGrow(ta) {
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
function escapeForTextarea(s) {
return (s ?? "").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>

View file

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

View file

@ -1,36 +0,0 @@
{% 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

@ -2,66 +2,7 @@
{% from 'components/combobox.html' import combobox %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
{% 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);
}
}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/combobox.css') }}">
{% endblock %}
{% block main %}
@ -69,12 +10,12 @@
<div class="container">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<button type="button" class="nav-link active" id="device-tab" data-bs-toggle="tab"
data-bs-target="#device-tab-pane">Devices</button>
<button type="button" class="nav-link active" id="device-tab"
data-bs-toggle="tab" data-bs-target="#device-tab-pane">Devices</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link" id="location-tab" data-bs-toggle="tab"
data-bs-target="#location-tab-pane">Locations</button>
<button type="button" class="nav-link" id="location-tab"
data-bs-toggle="tab" data-bs-target="#location-tab-pane">Locations</button>
</li>
</ul>
@ -83,51 +24,12 @@
<div class="tab-pane fade show active" id="device-tab-pane" tabindex="0">
<div class="row">
<div class="col">
<label for="brand" class="form-label">Brands</label>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
<label for="brand" class="form-label">Brand</label>
{{ combobox('brand', 'brand', 'Enter the name of a brand.', brands, 'id', 'name') }}
</div>
<div class="col">
<label for="devicetype" class="form-label">Device Types</label>
{# { 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 }}
<label for="devicetype" class="form-label">Device Type</label>
{{ combobox('devicetype', 'devicetype', 'Enter the description of a device type.', device_types, 'id', 'description') }}
</div>
</div>
</div>
@ -135,20 +37,19 @@
<div class="tab-pane fade" id="location-tab-pane" tabindex="0">
<div class="row">
<div class="col">
<label for="area" class="form-label">Areas</label>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
<label for="area" class="form-label">Area</label>
{{ combobox('area', 'area', 'Enter the name of an area.', areas, 'id', 'name') }}
</div>
<div class="col">
<label for="roomfunction" class="form-label">Descriptions</label>
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
<label for="roomfunction" class="form-label">Description</label>
{{ combobox('roomfunction', 'roomfunction', 'Enter a room description.', functions, 'id', 'description') }}
</div>
</div>
<div class="row mt-3">
<label for="rooms" class="form-label">
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>
<div class="col">
{{ rooms | safe }}
@ -162,266 +63,5 @@
{% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
{% 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');
}
}
<script src="{{ url_for('static', filename='js/components/combobox.js') }}"></script>
{% endblock %}

View file

@ -1,48 +1,40 @@
{% extends "base.html" %}
{% block main %}
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
<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 mb-0">
<thead>
<tr>
<th class="text-nowrap position-sticky top-0 bg-body border">Device Type</th>
{% for col in col_headers %}
{% if col.href %}
<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 %}
<th class="text-end position-sticky top-0 bg-body border">{{ col.label }}</th>
{% endif %}
<h1 class="display-4 text-center mb-3">Inventory Summary</h1>
<div class="table-responsive mx-5">
<table class="table table-sm table-striped table-hover table-bordered align-middle">
<thead>
<tr>
<th class="text-nowrap">Device Type</th>
{% for col in col_headers %}
{% if col.href %}
<th class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ col.href }}">{{ col.label }}</a></th>
{% else %}
<th class="text-end">{{ col.label }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table_rows %}
<tr>
<th class="text-nowrap">
{% if row.href %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %}
{{ row.label }}
{% endif %}
</th>
{% for cell in row.cells %}
{% if cell.href %}
<td class="text-end"><a class="link-dark link-underline link-underline-opacity-0" href="{{ cell.href }}">{{ cell.value }}</a></td>
{% else %}
<td class="text-end">{{ cell.value }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table_rows %}
{% set need_more = (row['cells'][-2]['value'] | int > 0) %}
<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 %}
<a class="link-dark link-underline link-underline-opacity-0" href="{{ row.href }}">{{ row.label }}</a>
{% else %}
{{ row.label }}
{% endif %}
</th>
{% for cell in row.cells %}
{% if cell.href %}
<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 %}
<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 %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -1,42 +0,0 @@
{% 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 []) %}
<div class="mt-3 form-floating">
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
<div class="mt-3">
<label class="form-label">Add update</label>
<textarea id="newUpdateInput" class="form-control auto-md" rows="3" placeholder="Write a new update..."></textarea>
<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-outline-secondary btn-sm" onclick="clearNewDraft()">Clear</button>
@ -16,7 +16,7 @@
<ul class="list-group mt-3">
{% for n in items %}
<li class="list-group-item" id="note-{{ n.id }}">
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<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>
@ -63,15 +63,44 @@
<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="{{ url_for('static', filename='js/components/markdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/utils/json.js') }}" defer></script>
<script>
// State (kept global for compatibility with your form serialization)
window.newDrafts = window.newDrafts || [];
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) {
return readJSONScript(`md-${id}`, "");
const el = document.getElementById(`md-${id}`);
return el ? JSON.parse(el.textContent || '""') : "";
}
function setMarkdown(id, md) {
@ -85,14 +114,14 @@
function autoGrow(ta) {
if (!ta) return;
if (CSS?.supports?.('field-sizing: content')) return;
ta.style.height = 'auto';
ta.style.height = (ta.scrollHeight + 5) + 'px';
}
// ---------- RENDERERS ----------
function renderExistingView(id) {
MarkDown.renderInto(document.getElementById(`editContainer${id}`), getMarkdown(id));
const container = document.getElementById(`editContainer${id}`);
renderHTML(container, getMarkdown(id));
}
function renderEditor(id) {
@ -130,7 +159,8 @@
const left = document.createElement('div');
left.className = 'w-100 markdown-body';
MarkDown.renderInto(left, md || '');
left.innerHTML = renderMarkdown(md || '');
enhanceLinks(left);
const right = document.createElement('div');
right.className = 'ms-3 d-flex flex-column align-items-end';
@ -233,7 +263,7 @@
if (!preview) return;
preview.classList.toggle('d-none');
if (!preview.classList.contains('d-none')) {
MarkDown.renderInto(preview, ta ? ta.value : "");
preview.innerHTML = renderMarkdown(ta ? ta.value : "");
}
}

View file

@ -1,33 +0,0 @@
<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

@ -1,37 +0,0 @@
<!-- 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>